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:
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+22
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user