概述
出站 Webhook 应采用至少一次投递语义,而不是发一次 POST 就结束。把 2xx 视为接收方已确认;408、425、429 和 5xx 属于可重试结果;其余 4xx 通常表示请求永远不会成功,应立即记录为永久失败。重试间隔按指数增长并设置上限,再加入抖动,避免大量失败请求在同一时刻冲击正在恢复的端点。Retry-After 作为延迟下限处理,但最终仍受最大延迟约束,防止异常响应头把任务停放数天。
步骤
先创建持久化失败表,保存 payload、尝试次数、最后状态和错误:
CREATE TABLE webhook_failures (
delivery_id uuid PRIMARY KEY,
endpoint_url text NOT NULL,
event_type text NOT NULL,
attempts integer NOT NULL,
last_status integer,
last_error text NOT NULL,
payload jsonb NOT NULL,
failed_at timestamptz NOT NULL DEFAULT now()
);
用 BullMQ 的 Queue 和 Worker 实现调度。第一次投递使用 deliveryId 作为 jobId,后续使用 ${deliveryId}-${attempt},自定义 jobId 不能包含冒号。延迟计算如下:
const exponential = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
const jittered = Math.floor(exponential * (0.5 + Math.random() * 0.5));
const delay = Math.min(Math.max(jittered, retryAfterMs(header) ?? 0), MAX_DELAY_MS);
先对指数延迟加抖动,再用 Retry-After 取下限,这样抖动不会把等待时间压到接收方要求的冷却时间以下。
Worker 在每次尝试时重新计算 HMAC 签名,并带上 x-webhook-delivery 和 x-webhook-attempt 请求头。网络错误和超时走重试;不可重试的 4xx 直接写入 webhook_failures。达到最大尝试次数后同样落表,便于人工重放。
验证
启动一个本地接收器,前两次返回 503 并带 Retry-After: 1,第三次返回 204。以 WEBHOOK_RETRY_BASE_MS=500 启动 Worker,再入队一个固定 delivery ID。接收器应依次打印 attempt 1、2、3,最后以 204 结束;查询 webhook_failures 应返回 0 行。随后让接收器固定返回 400,再投递一次,确认只产生一条永久失败记录且 attempts = 1,不会继续循环。
注意事项
重试不能无限进行,端点长时间下线时应依赖失败表和人工重放。不要在 job payload 中存放长期密钥,发送时再按端点 ID 加载。接收方必须按 delivery ID 去重,因为网络和调度器都可能造成重复投递。除了失败数量,还要监控最老等待任务的年龄;延迟队列持续增长往往意味着一次尚未告警的故障。