Skip to content

Commit d87542b

Browse files
fix(billing): classify non-transient Stripe errors via rawType + auth/permission (#3700)
* fix(billing): classify non-transient Stripe errors via rawType + auth/permission The retry short-circuit predicate checked err.type === 'invalid_request_error', but stripe-node sets err.type to the class name (StripeInvalidRequestError) and exposes the wire type on err.rawType — so that string was dead for SDK errors. It also missed StripeAuthenticationError (401) and StripePermissionError (403), which are equally deterministic and wasted the full retry budget. Extract isNonTransientStripeError() in billing.stripe-errors.js: match the SDK class names on .type plus invalid_request_error on .type/.rawType, and wire it into the PI-backfill retry. billing.admin.controller.js left as-is (different HTTP-422 mapping semantics; can adopt the helper later). Closes #3699 * fix(billing): add StripeIdempotencyError to non-transient set + cover rawType branch Pre-push review flagged the general isNonTransientStripeError helper omitted StripeIdempotencyError (400, deterministic — reused key with conflicting params). Add it. StripeCardError (402) stays excluded since some decline codes (processing_error, issuer_unavailable) are transient. Add tests for the rawType-only branch and the card exclusion; drop a mislabeled test.
1 parent b7ea2e3 commit d87542b

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Classify Stripe errors for retry / short-circuit decisions.
3+
*
4+
* stripe-node sets `err.type` to the error CLASS NAME (Error.js: `this.type = type || this.constructor.name`,
5+
* and every subclass passes its own name via `super(raw, 'StripeXError')`), while the raw API error type
6+
* string (e.g. `'invalid_request_error'`) lives on `err.rawType`. Unwrapped/raw API error objects instead
7+
* carry the wire type directly on `.type`. Both shapes are handled below.
8+
*/
9+
10+
const NON_TRANSIENT_STRIPE_ERROR_CLASSES = new Set([
11+
'StripeInvalidRequestError', // 400/404 — bad params, deterministic
12+
'StripeIdempotencyError', // 400 — idempotency key reused with conflicting params, deterministic
13+
'StripeAuthenticationError', // 401 — bad/missing API key, deterministic
14+
'StripePermissionError', // 403 — key lacks permission for the resource, deterministic
15+
]);
16+
17+
/**
18+
* True when a Stripe error is deterministic and will never succeed on retry
19+
* (invalid request, idempotency, authentication, or permission failures). Transient
20+
* errors (api_error/500, connection, rate_limit/429) return false so they keep
21+
* retrying. StripeCardError (402) is intentionally excluded — some decline codes
22+
* (processing_error, issuer_unavailable) are transient.
23+
*
24+
* @param {unknown} err
25+
* @returns {boolean}
26+
*/
27+
export function isNonTransientStripeError(err) {
28+
if (!err || typeof err !== 'object') return false;
29+
// SDK-wrapped errors expose the class name on `.type`.
30+
if (NON_TRANSIENT_STRIPE_ERROR_CLASSES.has(err.type)) return true;
31+
// SDK mirrors the wire type on `.rawType`; unwrapped API error objects carry it on `.type`.
32+
return err.rawType === 'invalid_request_error' || err.type === 'invalid_request_error';
33+
}

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import BillingResetService from './billing.reset.service.js';
1515
import billingEvents from '../lib/events.js';
1616
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
1717
import { retryWithBackoff } from '../lib/billing.retry.js';
18+
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';
1819

1920
/**
2021
* Treats a stripeSessionId as "unresolved" when absent, empty, or still the
@@ -326,11 +327,9 @@ const handleCheckoutPaymentCompleted = async (session) => {
326327
{
327328
attempts: 3,
328329
baseMs: 200,
329-
// Skip retries on Stripe invalid-request errors (invalid_request_error /
330-
// StripeInvalidRequestError) — these are deterministic client errors that never
331-
// succeed on retry and only delay the dead-letter path.
332-
shouldRetry: (err) =>
333-
err?.type !== 'StripeInvalidRequestError' && err?.type !== 'invalid_request_error',
330+
// Skip retries on deterministic Stripe errors (invalid request / auth / permission) —
331+
// they never succeed on retry and only delay the dead-letter path. See billing.stripe-errors.js.
332+
shouldRetry: (err) => !isNonTransientStripeError(err),
334333
},
335334
);
336335
} catch (err) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { describe, test, expect } from '@jest/globals';
5+
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';
6+
7+
/**
8+
* Unit tests for isNonTransientStripeError — deterministic Stripe errors that
9+
* should short-circuit retries (invalid request / authentication / permission),
10+
* across both SDK-wrapped (.type = class name) and raw (.type = wire string) shapes.
11+
*/
12+
describe('isNonTransientStripeError', () => {
13+
test.each([
14+
'StripeInvalidRequestError',
15+
'StripeIdempotencyError',
16+
'StripeAuthenticationError',
17+
'StripePermissionError',
18+
])('returns true for SDK error class %s (err.type = class name)', (type) => {
19+
expect(isNonTransientStripeError({ type })).toBe(true);
20+
});
21+
22+
test('returns true via the rawType branch when the class is not listed', () => {
23+
expect(isNonTransientStripeError({ type: 'StripeFutureUnknownError', rawType: 'invalid_request_error' })).toBe(
24+
true,
25+
);
26+
});
27+
28+
test('returns true for an unwrapped API error object (type = invalid_request_error)', () => {
29+
expect(isNonTransientStripeError({ type: 'invalid_request_error' })).toBe(true);
30+
});
31+
32+
test.each(['StripeAPIError', 'StripeConnectionError', 'StripeRateLimitError'])(
33+
'returns false for transient SDK error class %s',
34+
(type) => {
35+
expect(isNonTransientStripeError({ type })).toBe(false);
36+
},
37+
);
38+
39+
test('returns false for StripeCardError (402 — some decline codes are transient)', () => {
40+
expect(isNonTransientStripeError({ type: 'StripeCardError' })).toBe(false);
41+
});
42+
43+
test('returns false for a generic non-Stripe error', () => {
44+
expect(isNonTransientStripeError(new Error('boom'))).toBe(false);
45+
});
46+
47+
test('returns false for null / undefined / non-object', () => {
48+
expect(isNonTransientStripeError(null)).toBe(false);
49+
expect(isNonTransientStripeError(undefined)).toBe(false);
50+
expect(isNonTransientStripeError('invalid_request_error')).toBe(false);
51+
});
52+
});

0 commit comments

Comments
 (0)