Files
suaveplan/packages/api-stack/api/api-plugin-webhook/tests/integration/retry.test.ts
2026-05-27 23:29:53 +01:00

142 lines
4.3 KiB
TypeScript

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<void>((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<void>((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();
}
});
});