import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { createWebhookService, MemoryDeadLetterSink, MemoryEndpointStore, resetWebhookInstruments, WEBHOOK_CODES, } from "../../src/index.js"; async function startReceiver( handler: ( req: IncomingMessage, res: ServerResponse, attempt: number ) => void ) { let attempt = 0; const server: Server = createServer((req, res) => { req.on("data", () => undefined); req.on("end", () => { attempt += 1; handler(req, res, attempt); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve) ); const address = server.address(); if (address === null || typeof address === "string") { throw new Error("failed to bind"); } return { url: `http://127.0.0.1:${address.port}/hooks`, close: () => new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }), }; } function noopLogger() { return { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; } describe("integration: webhook retry", () => { it("retries 5xx then succeeds", { timeout: 30_000 }, async () => { const receiver = await startReceiver((_, res, attempt) => { if (attempt < 3) { res.writeHead(500); res.end("transient"); } else { res.writeHead(200); res.end("ok"); } }); try { resetWebhookInstruments(); const store = new MemoryEndpointStore({ clock: () => 0, idFactory: () => "ep-1", }); const service = createWebhookService({ endpointStore: store, deadLetterSink: new MemoryDeadLetterSink(), maxAttempts: 5, initialRetryDelayMs: 1, maxRetryDelayMs: 50, idempotencyGuard: null, logger: noopLogger(), clock: () => 0, fetch: globalThis.fetch.bind(globalThis), }); await service.register({ tenantId: null, url: receiver.url, secret: "shared-secret", events: ["order.created"], }); const result = await service.send("ep-1", "order.created", { x: 1, }); expect(result.status).toBe("delivered"); expect(result.attempts.length).toBe(3); expect(result.finalStatusCode).toBe(200); } finally { await receiver.close(); } }); it("4xx terminates without retry", { timeout: 30_000 }, async () => { let attempts = 0; const receiver = await startReceiver((_, res) => { attempts++; res.writeHead(422); res.end("client error"); }); try { resetWebhookInstruments(); const store = new MemoryEndpointStore({ clock: () => 0, idFactory: () => "ep-1", }); const service = createWebhookService({ endpointStore: store, deadLetterSink: new MemoryDeadLetterSink(), maxAttempts: 5, initialRetryDelayMs: 1, maxRetryDelayMs: 50, idempotencyGuard: null, logger: noopLogger(), clock: () => 0, fetch: globalThis.fetch.bind(globalThis), }); await service.register({ tenantId: null, url: receiver.url, secret: "shared-secret", events: ["evt"], }); await expect(service.send("ep-1", "evt", {})).rejects.toMatchObject( { webhookCode: WEBHOOK_CODES.DELIVERY_EXHAUSTED, } ); expect(attempts).toBe(1); } finally { await receiver.close(); } }); });