Add retry actions for platform webhook deliveries (#43846)

## What kind of change does this PR introduce?

Feature / UX update.

## What is the current behavior?

Platform webhook deliveries cannot be retried from the deliveries table
or the delivery details sheet.

## What is the new behavior?

- Adds a retry action to individual platform webhook deliveries in the
endpoint deliveries table
- Adds a retry action to the shared delivery details sheet footer
- Limits retry to non-success deliveries
- Applies the table action hit area guidance from the design system

 Successful deliveries should also not expose retry in this mock flow.

## Additional context

- Addresses DEPR-340.
- In the current mock implementation, queueing updates the existing
delivery in place: it sets the status to `pending`, clears
`responseCode`, and refreshes `attemptAt` to the current timestamp.
- The mock does not create a separate queued job or append a new
delivery record yet; the same delivery is resurfaced at the top of the
list because deliveries are sorted by latest attempt time.
This commit is contained in:
Danny White authored and GitHub committed 2026-03-18 01:13:33 +00:00
1 parent ee8cb336af
commit aa11d60874
5 files changed
+142 -4

No files matched your search

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
createInitialPlatformWebhooksState,
filterWebhookDeliveries,
retryWebhookDelivery,
} from './PlatformWebhooks.store'
describe('PlatformWebhooks.store', () => {
it('retries an individual delivery by resetting it to pending with a new timestamp', () => {
const nextState = retryWebhookDelivery(
createInitialPlatformWebhooksState('project'),
'project-delivery-2',
{ now: '2026-03-17T02:15:00.000Z' }
)
expect(nextState.deliveries.find((delivery) => delivery.id === 'project-delivery-2')).toEqual({
id: 'project-delivery-2',
endpointId: 'project-endpoint-1',
eventType: 'project.resource_exhausted',
status: 'pending',
responseCode: undefined,
attemptAt: '2026-03-17T02:15:00.000Z',
})
})
it('returns endpoint deliveries sorted by latest attempt first after a retry', () => {
const nextState = retryWebhookDelivery(
createInitialPlatformWebhooksState('project'),
'project-delivery-2',
{ now: '2026-03-17T02:15:00.000Z' }
)
expect(
filterWebhookDeliveries(nextState.deliveries, 'project-endpoint-1', '').map(
(delivery) => delivery.id
)
).toEqual(['project-delivery-2', 'project-delivery-1', 'project-delivery-3'])
})
it('does not retry successful deliveries', () => {
const initialState = createInitialPlatformWebhooksState('project')
const nextState = retryWebhookDelivery(initialState, 'project-delivery-1', {
now: '2026-03-17T02:15:00.000Z',
})
expect(nextState).toBe(initialState)
})
})
@@ -20,6 +20,10 @@ interface UpdateEndpointOptions {
headerIdFactory?: () => string
}
interface RetryDeliveryOptions {
now?: string
}
const secureRandomHex = (length: number) => {
if (length <= 0) return ''
@@ -180,6 +184,31 @@ export const regenerateWebhookEndpointSecret = (
}
}
export const retryWebhookDelivery = (
state: PlatformWebhooksState,
deliveryId: string,
options?: RetryDeliveryOptions
) => {
const delivery = state.deliveries.find((item) => item.id === deliveryId)
if (!delivery || delivery.status === 'success') return state
const now = options?.now ?? new Date().toISOString()
return {
...state,
deliveries: state.deliveries.map<WebhookDelivery>((delivery) => {
if (delivery.id !== deliveryId) return delivery
return {
...delivery,
status: 'pending',
responseCode: undefined,
attemptAt: now,
}
}),
}
}
export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => {
const normalizedSearch = normalizeSearch(search)
if (normalizedSearch.length === 0) return endpoints
@@ -205,6 +234,7 @@ export const filterWebhookDeliveries = (
`${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase()
return haystack.includes(normalizedSearch)
})
.sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
}
export const usePlatformWebhooksMockStore = (scope: WebhookScope) => {
@@ -263,5 +293,8 @@ export const usePlatformWebhooksMockStore = (scope: WebhookScope) => {
applyStateUpdate(() => next.state)
return next.signingSecret
},
retryDelivery: (deliveryId: string) => {
applyStateUpdate((prev) => retryWebhookDelivery(prev, deliveryId))
},
}
}
@@ -1,4 +1,4 @@
import { Copy } from 'lucide-react'
import { Copy, RotateCcw } from 'lucide-react'
import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils'
import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
@@ -11,6 +11,7 @@ import {
Separator,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
@@ -32,6 +33,7 @@ interface PlatformWebhooksDeliveryDetailsSheetProps {
selectedDelivery: WebhookDelivery | null
onCopy: (value: string, label: string) => void
onOpenChange: (open: boolean) => void
onRetryDelivery: (deliveryId: string) => void
onTabChange: (tab: 'event' | 'response') => void
}
@@ -44,8 +46,12 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
selectedDelivery,
onCopy,
onOpenChange,
onRetryDelivery,
onTabChange,
}: PlatformWebhooksDeliveryDetailsSheetProps) => {
const retryableDelivery =
selectedDelivery && selectedDelivery.status !== 'success' ? selectedDelivery : null
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent size="default" className="flex flex-col gap-0">
@@ -181,6 +187,18 @@ export const PlatformWebhooksDeliveryDetailsSheet = ({
</div>
</SheetSection>
)}
{retryableDelivery && (
<SheetFooter className="shrink-0">
<Button
type="default"
icon={<RotateCcw size={14} />}
onClick={() => onRetryDelivery(retryableDelivery.id)}
>
Retry delivery
</Button>
</SheetFooter>
)}
</SheetContent>
</Sheet>
)
@@ -1,7 +1,7 @@
import { Search } from 'lucide-react'
import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
import { RotateCcw, Search } from 'lucide-react'
import {
Badge,
Card,
@@ -15,6 +15,7 @@ import {
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types'
import { statusBadgeVariant } from './PlatformWebhooksView.utils'
@@ -37,6 +38,7 @@ interface PlatformWebhooksEndpointDetailsProps {
selectedEndpoint: WebhookEndpoint
onDeliverySearchChange: (value: string) => void
onOpenDelivery: (deliveryId: string) => void
onRetryDelivery: (deliveryId: string) => void
}
export const PlatformWebhooksEndpointDetails = ({
@@ -45,6 +47,7 @@ export const PlatformWebhooksEndpointDetails = ({
selectedEndpoint,
onDeliverySearchChange,
onOpenDelivery,
onRetryDelivery,
}: PlatformWebhooksEndpointDetailsProps) => {
const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0
@@ -122,6 +125,9 @@ export const PlatformWebhooksEndpointDetails = ({
<TableHead>Event type</TableHead>
<TableHead>Response</TableHead>
<TableHead>Attempted</TableHead>
<TableHead className="w-1">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -159,11 +165,32 @@ export const PlatformWebhooksEndpointDetails = ({
<TableCell>
<TimestampInfo className="text-sm" utcTimestamp={delivery.attemptAt} />
</TableCell>
<TableCell className="w-1 text-right">
<div className="flex h-full items-center justify-end">
{delivery.status !== 'success' && (
<ButtonTooltip
type="default"
className="w-7 shrink-0 hit-area-2"
icon={<RotateCcw size={14} />}
aria-label={`Retry ${delivery.id}`}
tooltip={{ content: { side: 'top', text: 'Retry' } }}
onClick={(event) => {
event.stopPropagation()
onRetryDelivery(delivery.id)
}}
onKeyDown={(event) => event.stopPropagation()}
/>
)}
{delivery.status === 'success' && (
<span aria-hidden className="size-7 shrink-0" />
)}
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4}>No deliveries found</TableCell>
<TableCell colSpan={5}>No deliveries found</TableCell>
</TableRow>
)}
</TableBody>
@@ -73,6 +73,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
updateEndpoint,
deleteEndpoint,
regenerateSecret,
retryDelivery,
} = usePlatformWebhooksMockStore(scope)
const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString)
const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES))
@@ -258,6 +259,14 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
toast.success('Signing secret regenerated')
}
const handleRetryDelivery = (deliveryId: string) => {
const delivery = deliveries.find((item) => item.id === deliveryId)
if (!delivery || delivery.status === 'success') return
retryDelivery(deliveryId)
toast.success('Delivery queued for retry')
}
const handleCopy = (value: string, label: string) => {
copyToClipboard(value)
toast.success(`Copied ${label}`)
@@ -357,6 +366,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
setDeliveryDetailsTab('event')
setDeliveryId(id)
}}
onRetryDelivery={handleRetryDelivery}
/>
)}
</PageSectionContent>
@@ -372,6 +382,7 @@ export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPage
selectedDelivery={selectedDelivery}
onCopy={handleCopy}
onOpenChange={(open) => !open && setDeliveryId(null)}
onRetryDelivery={handleRetryDelivery}
onTabChange={setDeliveryDetailsTab}
/>