Skip to content

Commit b3f08d5

Browse files
fix(billing): short-circuit retryWithBackoff on non-transient Stripe errors
retryWithBackoff retried every error type, wasting ~600ms of backoff on deterministic StripeInvalidRequestError before dead-lettering. Add an optional shouldRetry(err) predicate (default: always retry) and pass a non-transient classifier at the PI-backfill call site. The err.stack finding from the issue is already satisfied by #3690 (both logger.error calls include stack) — no change needed. Closes #3691
1 parent 3df0034 commit b3f08d5

3 files changed

Lines changed: 100 additions & 4 deletions

File tree

modules/billing/lib/billing.retry.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,37 @@
66
* each non-final attempt i.
77
*
88
* Returns the result of the first successful call, or throws the last
9-
* error after all attempts are exhausted.
9+
* error after all attempts are exhausted. If `shouldRetry(err)` returns
10+
* false for a thrown error, the error is rethrown immediately without
11+
* further attempts or delay.
1012
*
1113
* @param {() => Promise<T>} fn - Async function to attempt.
1214
* @param {object} [opts]
1315
* @param {number} [opts.attempts=3] - Maximum number of attempts (including the first call).
1416
* @param {number} [opts.baseMs=200] - Base delay in ms for the first retry.
17+
* @param {(err: unknown) => boolean} [opts.shouldRetry] - Predicate; return false to stop retrying for a given error. Defaults to always retry.
1518
* @returns {Promise<T>}
1619
*/
17-
export async function retryWithBackoff(fn, { attempts = 3, baseMs = 200 } = {}) {
20+
export async function retryWithBackoff(fn, { attempts = 3, baseMs = 200, shouldRetry = () => true } = {}) {
1821
if (!Number.isInteger(attempts) || attempts < 1) {
1922
throw new TypeError(`retryWithBackoff: attempts must be a positive integer, received ${attempts}`);
2023
}
2124
if (!Number.isFinite(baseMs) || baseMs < 0) {
2225
throw new TypeError(`retryWithBackoff: baseMs must be a non-negative finite number, received ${baseMs}`);
2326
}
27+
if (typeof shouldRetry !== 'function') {
28+
throw new TypeError(`retryWithBackoff: shouldRetry must be a function, received ${typeof shouldRetry}`);
29+
}
2430
let lastErr;
2531
for (let i = 0; i < attempts; i++) {
2632
try {
2733
return await fn();
2834
} catch (err) {
2935
lastErr = err;
36+
// Deterministic errors never succeed on retry — rethrow now instead of burning the backoff budget.
37+
if (!shouldRetry(err)) {
38+
throw err;
39+
}
3040
if (i < attempts - 1) {
3141
await new Promise((resolve) => setTimeout(resolve, baseMs * 2 ** i));
3242
}

modules/billing/services/billing.webhook.service.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,18 @@ const handleCheckoutPaymentCompleted = async (session) => {
320320
organizationId,
321321
packId,
322322
kind: 'extras',
323-
stripeSessionId, // real cs_* ID (replaces SENTINEL_PENDING)
323+
stripeSessionId, // real cs_* ID (replaces SENTINEL_PENDING)
324324
},
325325
}),
326-
{ attempts: 3, baseMs: 200 },
326+
{
327+
attempts: 3,
328+
baseMs: 200,
329+
// Skip retries on deterministic Stripe client errors (bad params / auth) — they
330+
// never succeed on retry and only delay the dead-letter path. Same classification
331+
// as billing.admin.controller.js.
332+
shouldRetry: (err) =>
333+
err?.type !== 'StripeInvalidRequestError' && err?.type !== 'invalid_request_error',
334+
},
327335
);
328336
} catch (err) {
329337
logger.error(
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, describe, test, afterEach, expect } from '@jest/globals';
5+
import { retryWithBackoff } from '../lib/billing.retry.js';
6+
7+
/**
8+
* Unit tests for retryWithBackoff:
9+
* - success / transient-retry / exhaustion paths
10+
* - shouldRetry short-circuit on non-transient (deterministic) errors
11+
* - argument validation
12+
* Retry-delay paths use fake timers so the suite never incurs real backoff waits.
13+
*/
14+
describe('retryWithBackoff', () => {
15+
afterEach(() => {
16+
jest.useRealTimers();
17+
});
18+
19+
test('returns the result on first success without retrying', async () => {
20+
const fn = jest.fn().mockResolvedValue('ok');
21+
await expect(retryWithBackoff(fn)).resolves.toBe('ok');
22+
expect(fn).toHaveBeenCalledTimes(1);
23+
});
24+
25+
test('retries a transient failure then resolves', async () => {
26+
jest.useFakeTimers();
27+
const fn = jest.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce('ok');
28+
const promise = retryWithBackoff(fn, { attempts: 3, baseMs: 200 });
29+
await jest.runAllTimersAsync();
30+
await expect(promise).resolves.toBe('ok');
31+
expect(fn).toHaveBeenCalledTimes(2);
32+
});
33+
34+
test('throws the last error after exhausting all attempts', async () => {
35+
jest.useFakeTimers();
36+
const fn = jest.fn().mockRejectedValue(new Error('always fails'));
37+
const promise = retryWithBackoff(fn, { attempts: 3, baseMs: 200 });
38+
const assertion = expect(promise).rejects.toThrow('always fails');
39+
await jest.runAllTimersAsync();
40+
await assertion;
41+
expect(fn).toHaveBeenCalledTimes(3);
42+
});
43+
44+
test('short-circuits (1 call, not 3) when shouldRetry returns false', async () => {
45+
const err = Object.assign(new Error('bad params'), { type: 'invalid_request_error' });
46+
const fn = jest.fn().mockRejectedValue(err);
47+
await expect(
48+
retryWithBackoff(fn, {
49+
attempts: 3,
50+
baseMs: 200,
51+
shouldRetry: (e) => e?.type !== 'invalid_request_error',
52+
}),
53+
).rejects.toThrow('bad params');
54+
expect(fn).toHaveBeenCalledTimes(1);
55+
});
56+
57+
test('retries to exhaustion when shouldRetry returns true', async () => {
58+
jest.useFakeTimers();
59+
const fn = jest.fn().mockRejectedValue(new Error('transient'));
60+
const promise = retryWithBackoff(fn, {
61+
attempts: 3,
62+
baseMs: 200,
63+
shouldRetry: () => true,
64+
});
65+
const assertion = expect(promise).rejects.toThrow('transient');
66+
await jest.runAllTimersAsync();
67+
await assertion;
68+
expect(fn).toHaveBeenCalledTimes(3);
69+
});
70+
71+
test('rejects invalid attempts / baseMs / shouldRetry with TypeError', async () => {
72+
const fn = jest.fn().mockResolvedValue('ok');
73+
await expect(retryWithBackoff(fn, { attempts: 0 })).rejects.toThrow(TypeError);
74+
await expect(retryWithBackoff(fn, { baseMs: -1 })).rejects.toThrow(TypeError);
75+
await expect(retryWithBackoff(fn, { shouldRetry: 'nope' })).rejects.toThrow(TypeError);
76+
expect(fn).not.toHaveBeenCalled();
77+
});
78+
});

0 commit comments

Comments
 (0)