feat(webhook): retry delivery — POST /deliveries/{id}/retry + UI кнопка

Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.

Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
  status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now

UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
  (delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']

Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.
This commit is contained in:
Zimin A.N.
2026-05-06 20:36:17 +03:00
parent d5a28d251d
commit 2e3f2ddd16
4 changed files with 110 additions and 32 deletions
+27
View File
@@ -6,6 +6,7 @@ import {
type CreateWebhookSubscriptionRequest, type CreateWebhookSubscriptionRequest,
type DictionaryDetail, type DictionaryDetail,
type RecordResponse, type RecordResponse,
type WebhookDelivery,
type WebhookSubscription, type WebhookSubscription,
} from './client' } from './client'
@@ -164,3 +165,29 @@ export const useRotateWebhookSecret = () => {
}, },
}) })
} }
/**
* Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0,
* dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick.
*
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
* delivery без ожидания TTL bypass'а.
*/
export const useRetryWebhookDelivery = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (deliveryId: number): Promise<WebhookDelivery> => {
const { data } = await apiClient.post<WebhookDelivery>(
`/admin/webhooks/deliveries/${deliveryId}/retry`,
)
return data
},
onSuccess: () => {
// Invalidate всё что показывает delivery rows: detail page deliveries,
// global DLQ list. Cheaper чем targeted invalidation т.к. delivery
// counts маленькие.
qc.invalidateQueries({ queryKey: ['webhooks', 'deliveries'] })
qc.invalidateQueries({ queryKey: ['webhooks', 'dlq'] })
},
})
}
+4
View File
@@ -64,6 +64,8 @@ i18n
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка', 'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
'webhooks.deliveries.col.lastStatusCode': 'HTTP статус', 'webhooks.deliveries.col.lastStatusCode': 'HTTP статус',
'webhooks.deliveries.col.lastError': 'Последняя ошибка', 'webhooks.deliveries.col.lastError': 'Последняя ошибка',
'webhooks.deliveries.action.retry': 'Повторить доставку',
'webhooks.deliveries.confirmRetry': 'Сбросить attemptCount=0 и поставить в очередь повтор? Receiver получит payload снова.',
'audit.title': 'Журнал аудита', 'audit.title': 'Журнал аудита',
'audit.subtitle': 'Юридически значимый лог изменений НСИ', 'audit.subtitle': 'Юридически значимый лог изменений НСИ',
'audit.filter.dictionary': 'Справочник', 'audit.filter.dictionary': 'Справочник',
@@ -306,6 +308,8 @@ i18n
'webhooks.deliveries.col.lastAttempt': 'Last attempt', 'webhooks.deliveries.col.lastAttempt': 'Last attempt',
'webhooks.deliveries.col.lastStatusCode': 'HTTP status', 'webhooks.deliveries.col.lastStatusCode': 'HTTP status',
'webhooks.deliveries.col.lastError': 'Last error', 'webhooks.deliveries.col.lastError': 'Last error',
'webhooks.deliveries.action.retry': 'Retry delivery',
'webhooks.deliveries.confirmRetry': 'Reset attemptCount=0 and queue a retry? The receiver will get the payload again.',
'audit.title': 'Audit log', 'audit.title': 'Audit log',
'audit.subtitle': 'Legally significant log of NSI changes', 'audit.subtitle': 'Legally significant log of NSI changes',
'audit.filter.dictionary': 'Dictionary', 'audit.filter.dictionary': 'Dictionary',
+27 -2
View File
@@ -18,6 +18,7 @@ import {
TableRow, TableRow,
} from '@nstart/ui' } from '@nstart/ui'
import { import {
ArrowClockwiseIcon,
ArrowsClockwiseIcon, ArrowsClockwiseIcon,
KeyIcon, KeyIcon,
TrashIcon, TrashIcon,
@@ -28,6 +29,7 @@ import {
} from '@/api/queries' } from '@/api/queries'
import { import {
useDeleteWebhook, useDeleteWebhook,
useRetryWebhookDelivery,
useRotateWebhookSecret, useRotateWebhookSecret,
} from '@/api/mutations' } from '@/api/mutations'
import { SCOPE_DOT } from '@/lib/scope-style' import { SCOPE_DOT } from '@/lib/scope-style'
@@ -51,6 +53,12 @@ function WebhookDetailPage() {
const deliveries = useWebhookDeliveries(id, page, 50) const deliveries = useWebhookDeliveries(id, page, 50)
const rotateMut = useRotateWebhookSecret() const rotateMut = useRotateWebhookSecret()
const deleteMut = useDeleteWebhook() const deleteMut = useDeleteWebhook()
const retryMut = useRetryWebhookDelivery()
const handleRetry = (deliveryId: number) => {
if (!window.confirm(t('webhooks.deliveries.confirmRetry'))) return
retryMut.mutate(deliveryId)
}
const [revealedSecret, setRevealedSecret] = useState<string | null>(null) const [revealedSecret, setRevealedSecret] = useState<string | null>(null)
const handleRotate = () => { const handleRotate = () => {
@@ -193,10 +201,15 @@ function WebhookDetailPage() {
<TableHeaderCell>{t('webhooks.deliveries.col.lastAttempt')}</TableHeaderCell> <TableHeaderCell>{t('webhooks.deliveries.col.lastAttempt')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.deliveries.col.lastStatusCode')}</TableHeaderCell> <TableHeaderCell>{t('webhooks.deliveries.col.lastStatusCode')}</TableHeaderCell>
<TableHeaderCell>{t('webhooks.deliveries.col.lastError')}</TableHeaderCell> <TableHeaderCell>{t('webhooks.deliveries.col.lastError')}</TableHeaderCell>
<TableHeaderCell align="right">
{t('dict.col.actions')}
</TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{deliveries.data.content.map((d) => ( {deliveries.data.content.map((d) => {
const canRetry = d.status === 'dlq' || d.status === 'retrying'
return (
<TableRow key={d.id}> <TableRow key={d.id}>
<TableCell> <TableCell>
<span className="font-mono text-2xs">{d.outboxEventId}</span> <span className="font-mono text-2xs">{d.outboxEventId}</span>
@@ -226,8 +239,20 @@ function WebhookDetailPage() {
{d.lastError ?? '—'} {d.lastError ?? '—'}
</span> </span>
</TableCell> </TableCell>
<TableCell align="right">
{canRetry && (
<IconButton
label={t('webhooks.deliveries.action.retry')}
variant="default"
icon={<ArrowClockwiseIcon weight="regular" />}
disabled={retryMut.isPending}
onClick={() => handleRetry(d.id)}
/>
)}
</TableCell>
</TableRow> </TableRow>
))} )
})}
</TableBody> </TableBody>
</Table> </Table>
</Panel> </Panel>
@@ -142,6 +142,28 @@ public class WebhookSubscriptionController {
return deliveries.map(WebhookDeliveryResponse::from); return deliveries.map(WebhookDeliveryResponse::from);
} }
/**
* Replay delivery из DLQ или retrying state — сбрасывает attemptCount=0,
* dlqAt=null, status=retrying, nextAttemptAt=now. Dispatcher подхватит на
* следующем sendTick.
*
* <p>Use case: оператор fixed receiver URL после deploy/cert renewal,
* хочет replay'нуть failed delivery без ожидания TTL bypass'а.
*
* <p>RBAC: RESTRICTED — это destructive op (можно spam'ить receiver).
*/
@PostMapping("/deliveries/{id}/retry")
public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) {
requireAdmin();
WebhookDelivery delivery = deliveryRepository.findById(id)
.orElseThrow(() -> OrdinisException.notFound(
"webhook_delivery_not_found",
"Webhook delivery " + id + " not found."));
delivery.resetDlq();
deliveryRepository.save(delivery);
return WebhookDeliveryResponse.from(delivery);
}
// === Helpers === // === Helpers ===
/** RESTRICTED scope обязателен для mutating endpoints. */ /** RESTRICTED scope обязателен для mutating endpoints. */