Skip to content

Commit 9bb24d4

Browse files
committed
fix(tw): retry Stripe 429s in the test harness (TW_WORKER_MODE-gated)
stripe-node's maxNetworkRetries never retries 429s (_shouldRetry covers connection errors, 409, 5xx, and stripe-should-retry only), so rate-limit collateral killed tests outright — the largest single infra-flake cluster (~30 tests across the measured runs). withTwStripeRateLimitRetry wraps the three factory-constructed clients with a lazy deep Proxy: thenable-returning calls get up to 3 retries on 429/rate_limit with 1s/2s/4s jittered backoff (8s cap), honoring Retry-After when larger. Wrapping sits OUTSIDE instrumentStripe so each retry gets its own OTel span. Identity function unless TW_WORKER_MODE=1 and NODE_ENV != production — the same double-gate as stripeRetryOptions, so prod clients are provably untouched. Retrying 429s specifically is idempotency-safe: a rate-limited request was rejected before processing. Known trade-off: rewrapped calls return plain promises, dropping Stripe's autoPaging* extras — verified unused repo-wide.
1 parent 57d10ac commit 9bb24d4

3 files changed

Lines changed: 100 additions & 3 deletions

File tree

server/src/external/connect/createStripeCli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
initPlatformStripe,
1717
stripeRetryOptions,
1818
} from "./initStripeCli.js";
19+
import { withTwStripeRateLimitRetry } from "./twStripeRateLimitRetry.js";
1920

2021
export const createStripeCli = ({
2122
org,
@@ -64,7 +65,9 @@ export const createStripeCli = ({
6465
: undefined,
6566
...stripeRetryOptions(),
6667
});
67-
return skipInstrumentation ? client : instrumentStripe({ client });
68+
return withTwStripeRateLimitRetry(
69+
skipInstrumentation ? client : instrumentStripe({ client }),
70+
);
6871
},
6972
});
7073
}

server/src/external/connect/initStripeCli.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "./clientCache/cacheKeyUtils.js";
1717
import { getOrCreateStripeClient } from "./clientCache/stripeClientCache.js";
1818
import { getConnectWebhookSecret } from "./connectUtils.js";
19+
import { withTwStripeRateLimitRetry } from "./twStripeRateLimitRetry.js";
1920

2021
// tw-swarm seam: a forked worker can't change its snapshotted process env, so
2122
// its per-worker pool key is read from a file written at fork-bind time.
@@ -98,7 +99,9 @@ export const initMasterStripe = (params?: {
9899
: undefined,
99100
...stripeRetryOptions(),
100101
});
101-
return params?.skipInstrumentation ? client : instrumentStripe({ client });
102+
return withTwStripeRateLimitRetry(
103+
params?.skipInstrumentation ? client : instrumentStripe({ client }),
104+
);
102105
},
103106
});
104107
};
@@ -158,7 +161,9 @@ export const initPlatformStripe = ({
158161
apiVersion: legacyVersion ? ("2025-02-24.acacia" as any) : undefined,
159162
...stripeRetryOptions(),
160163
});
161-
return skipInstrumentation ? client : instrumentStripe({ client });
164+
return withTwStripeRateLimitRetry(
165+
skipInstrumentation ? client : instrumentStripe({ client }),
166+
);
162167
},
163168
});
164169
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type Stripe from "stripe";
2+
3+
/**
4+
* tw-swarm seam: retry-on-429 for the test harness only, same gate as
5+
* stripeRetryOptions/twWorkerStripeKey. The SDK's maxNetworkRetries never
6+
* retries 429s (RequestSender._shouldRetry: connection errors, 409, 5xx,
7+
* stripe-should-retry only), so rate-limit collateral kills tests outright.
8+
* Deliberately NOT enabled in prod: blind retries on non-idempotent billing
9+
* calls risk double-charging when a request succeeded but the response died.
10+
*/
11+
const isTwWorkerHarness = (): boolean =>
12+
process.env.TW_WORKER_MODE === "1" &&
13+
process.env.NODE_ENV !== "production";
14+
15+
const MAX_RETRIES = 3;
16+
const BASE_DELAY_MS = 1000;
17+
const MAX_DELAY_MS = 8000;
18+
19+
const isRateLimitError = (error: unknown): boolean => {
20+
const e = error as { statusCode?: number; code?: string; type?: string };
21+
return (
22+
e?.statusCode === 429 ||
23+
e?.code === "rate_limit" ||
24+
e?.type === "StripeRateLimitError"
25+
);
26+
};
27+
28+
/** Exponential backoff with jitter; honors Stripe's Retry-After when larger. */
29+
const retryDelayMs = (attempt: number, error: unknown): number => {
30+
const headers = (error as { headers?: Record<string, string> })?.headers;
31+
const retryAfterSec = Number(headers?.["retry-after"]);
32+
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
33+
const jittered = backoff * (0.5 + Math.random() * 0.5);
34+
return Number.isFinite(retryAfterSec) && retryAfterSec > 0
35+
? Math.max(retryAfterSec * 1000, jittered)
36+
: jittered;
37+
};
38+
39+
const wrapFn = (
40+
fn: (...args: unknown[]) => unknown,
41+
self: unknown,
42+
): ((...args: unknown[]) => unknown) =>
43+
function wrapped(...args: unknown[]) {
44+
const invoke = () => Reflect.apply(fn, self, args);
45+
const first = invoke();
46+
// Sync return (config getters etc.) — pass through untouched.
47+
if (!(first && typeof (first as Promise<unknown>).then === "function")) {
48+
return first;
49+
}
50+
return (async () => {
51+
let attempt = 0;
52+
let pending = first as Promise<unknown>;
53+
for (;;) {
54+
try {
55+
return await pending;
56+
} catch (error) {
57+
if (!isRateLimitError(error) || attempt >= MAX_RETRIES) {
58+
throw error;
59+
}
60+
await new Promise((resolve) =>
61+
setTimeout(resolve, retryDelayMs(attempt, error)),
62+
);
63+
attempt += 1;
64+
pending = invoke() as Promise<unknown>;
65+
}
66+
}
67+
})();
68+
};
69+
70+
const wrapNamespace = <T extends object>(target: T): T =>
71+
new Proxy(target, {
72+
get(t, prop, _receiver) {
73+
const value = Reflect.get(t, prop, t);
74+
if (typeof prop !== "string" || prop.startsWith("_")) return value;
75+
if (typeof value === "function") {
76+
return wrapFn(value as (...args: unknown[]) => unknown, t);
77+
}
78+
if (value && typeof value === "object" && !Array.isArray(value)) {
79+
return wrapNamespace(value);
80+
}
81+
return value;
82+
},
83+
});
84+
85+
/** Identity outside the tw worker harness — prod clients are untouched. */
86+
export const withTwStripeRateLimitRetry = (client: Stripe): Stripe => {
87+
if (!isTwWorkerHarness()) return client;
88+
return wrapNamespace(client);
89+
};

0 commit comments

Comments
 (0)