diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index c79950a..7351951 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -160,21 +160,48 @@ export class CheckoutService { } const webhookUrl = checkout.platform.webhook_url; + const body = JSON.stringify(orderData); + const headers = { + "Content-Type": "application/json", + "X-Event-Type": eventType, + "Webhook-Id": uuidv4(), + "Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(), + }; + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(webhookUrl, { + method: "POST", + headers, + body, + }); + if (response.ok) { + return; + } + if (response.status < 500) { + console.error( + `Webhook at ${webhookUrl} rejected delivery with status ${response.status}` + ); + return; + } + } catch (e) { + if (attempt === maxAttempts) { + console.error(`Failed to notify webhook at ${webhookUrl}`, e); + return; + } + } - try { - await fetch(webhookUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Event-Type": eventType, - "Webhook-Id": uuidv4(), - "Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(), - }, - body: JSON.stringify(orderData), - }); - } catch (e) { - console.error(`Failed to notify webhook at ${webhookUrl}`, e); + if (attempt < maxAttempts) { + await new Promise((resolve) => + setTimeout(resolve, 100 * 2 ** (attempt - 1)) + ); + } } + + console.error( + `Failed to notify webhook at ${webhookUrl} after ${maxAttempts} attempts` + ); } private addressesMatch( diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts index b8d7180..8f8d48f 100644 --- a/rest/nodejs/test/webhook.test.ts +++ b/rest/nodejs/test/webhook.test.ts @@ -82,7 +82,8 @@ type CapturedRequest = { // mirroring the delivered wire request exactly. async function notifyAndCapture( checkout: unknown, - eventType: string + eventType: string, + responseOutcomes: Array = [200] ): Promise { const captured: CapturedRequest[] = []; const originalFetch = globalThis.fetch; @@ -97,7 +98,11 @@ async function notifyAndCapture( headers, body: typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody, }); - return new Response(null, { status: 200 }); + const outcome = responseOutcomes[captured.length - 1] ?? 200; + if (outcome instanceof Error) { + throw outcome; + } + return new Response(null, { status: outcome }); }) as typeof globalThis.fetch; try { @@ -179,6 +184,73 @@ test("webhook delivers the bare order object as the body", async () => { ); }); +function checkoutWithOrder() { + return { + id: CHECKOUT_ID, + platform: { webhook_url: WEBHOOK_URL }, + order: { + id: ORDER_ID, + permalink_url: `http://localhost:8080/orders/${ORDER_ID}`, + }, + }; +} + +test("webhook retries a 5xx response and preserves event identity", async () => { + seedOrder(); + + const captured = await notifyAndCapture( + checkoutWithOrder(), + "order_placed", + [500, 200] + ); + + assert.equal(captured.length, 2); + assert.equal( + captured[1]!.headers["Webhook-Id"], + captured[0]!.headers["Webhook-Id"] + ); + assert.equal( + captured[1]!.headers["Webhook-Timestamp"], + captured[0]!.headers["Webhook-Timestamp"] + ); + assert.deepEqual(captured[1]!.body, captured[0]!.body); +}); + +test("webhook retries after a transport error", async () => { + seedOrder(); + + const captured = await notifyAndCapture(checkoutWithOrder(), "order_placed", [ + new TypeError("connection reset"), + 200, + ]); + + assert.equal(captured.length, 2); +}); + +test("webhook retries are bounded after repeated 5xx responses", async () => { + seedOrder(); + + const captured = await notifyAndCapture( + checkoutWithOrder(), + "order_placed", + [500, 502, 503] + ); + + assert.equal(captured.length, 3); +}); + +test("webhook does not retry a permanent 4xx rejection", async () => { + seedOrder(); + + const captured = await notifyAndCapture( + checkoutWithOrder(), + "order_placed", + [400] + ); + + assert.equal(captured.length, 1); +}); + test("no webhook is delivered when there is no order", async () => { // A checkout with no order (e.g. created but not completed) must never post, // because the body must always be a valid order object.