Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
76 changes: 74 additions & 2 deletions rest/nodejs/test/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ type CapturedRequest = {
// mirroring the delivered wire request exactly.
async function notifyAndCapture(
checkout: unknown,
eventType: string
eventType: string,
responseOutcomes: Array<number | Error> = [200]
): Promise<CapturedRequest[]> {
const captured: CapturedRequest[] = [];
const originalFetch = globalThis.fetch;
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading