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
33 changes: 33 additions & 0 deletions modules/billing/lib/billing.stripe-errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Classify Stripe errors for retry / short-circuit decisions.
*
* stripe-node sets `err.type` to the error CLASS NAME (Error.js: `this.type = type || this.constructor.name`,
* and every subclass passes its own name via `super(raw, 'StripeXError')`), while the raw API error type
* string (e.g. `'invalid_request_error'`) lives on `err.rawType`. Unwrapped/raw API error objects instead
* carry the wire type directly on `.type`. Both shapes are handled below.
*/

const NON_TRANSIENT_STRIPE_ERROR_CLASSES = new Set([
'StripeInvalidRequestError', // 400/404 — bad params, deterministic
'StripeIdempotencyError', // 400 — idempotency key reused with conflicting params, deterministic
'StripeAuthenticationError', // 401 — bad/missing API key, deterministic
'StripePermissionError', // 403 — key lacks permission for the resource, deterministic
]);

/**
* True when a Stripe error is deterministic and will never succeed on retry
* (invalid request, idempotency, authentication, or permission failures). Transient
* errors (api_error/500, connection, rate_limit/429) return false so they keep
* retrying. StripeCardError (402) is intentionally excluded — some decline codes
* (processing_error, issuer_unavailable) are transient.
*
* @param {unknown} err
* @returns {boolean}
*/
export function isNonTransientStripeError(err) {
if (!err || typeof err !== 'object') return false;
// SDK-wrapped errors expose the class name on `.type`.
if (NON_TRANSIENT_STRIPE_ERROR_CLASSES.has(err.type)) return true;
// SDK mirrors the wire type on `.rawType`; unwrapped API error objects carry it on `.type`.
return err.rawType === 'invalid_request_error' || err.type === 'invalid_request_error';
}
9 changes: 4 additions & 5 deletions modules/billing/services/billing.webhook.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import BillingResetService from './billing.reset.service.js';
import billingEvents from '../lib/events.js';
import { SENTINEL_PENDING } from '../lib/billing.constants.js';
import { retryWithBackoff } from '../lib/billing.retry.js';
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';

/**
* Treats a stripeSessionId as "unresolved" when absent, empty, or still the
Expand Down Expand Up @@ -326,11 +327,9 @@ const handleCheckoutPaymentCompleted = async (session) => {
{
attempts: 3,
baseMs: 200,
// Skip retries on Stripe invalid-request errors (invalid_request_error /
// StripeInvalidRequestError) — these are deterministic client errors that never
// succeed on retry and only delay the dead-letter path.
shouldRetry: (err) =>
err?.type !== 'StripeInvalidRequestError' && err?.type !== 'invalid_request_error',
// Skip retries on deterministic Stripe errors (invalid request / auth / permission) —
// they never succeed on retry and only delay the dead-letter path. See billing.stripe-errors.js.
shouldRetry: (err) => !isNonTransientStripeError(err),
},
);
} catch (err) {
Expand Down
52 changes: 52 additions & 0 deletions modules/billing/tests/billing.stripe-errors.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Module dependencies.
*/
import { describe, test, expect } from '@jest/globals';
import { isNonTransientStripeError } from '../lib/billing.stripe-errors.js';

/**
* Unit tests for isNonTransientStripeError — deterministic Stripe errors that
* should short-circuit retries (invalid request / authentication / permission),
* across both SDK-wrapped (.type = class name) and raw (.type = wire string) shapes.
*/
describe('isNonTransientStripeError', () => {
test.each([
'StripeInvalidRequestError',
'StripeIdempotencyError',
'StripeAuthenticationError',
'StripePermissionError',
])('returns true for SDK error class %s (err.type = class name)', (type) => {
expect(isNonTransientStripeError({ type })).toBe(true);
});

test('returns true via the rawType branch when the class is not listed', () => {
expect(isNonTransientStripeError({ type: 'StripeFutureUnknownError', rawType: 'invalid_request_error' })).toBe(
true,
);
});

test('returns true for an unwrapped API error object (type = invalid_request_error)', () => {
expect(isNonTransientStripeError({ type: 'invalid_request_error' })).toBe(true);
});

test.each(['StripeAPIError', 'StripeConnectionError', 'StripeRateLimitError'])(
'returns false for transient SDK error class %s',
(type) => {
expect(isNonTransientStripeError({ type })).toBe(false);
},
);

test('returns false for StripeCardError (402 — some decline codes are transient)', () => {
expect(isNonTransientStripeError({ type: 'StripeCardError' })).toBe(false);
});

test('returns false for a generic non-Stripe error', () => {
expect(isNonTransientStripeError(new Error('boom'))).toBe(false);
});

test('returns false for null / undefined / non-object', () => {
expect(isNonTransientStripeError(null)).toBe(false);
expect(isNonTransientStripeError(undefined)).toBe(false);
expect(isNonTransientStripeError('invalid_request_error')).toBe(false);
});
});
Loading