docs/solutions/integration-issues/vendor-sdk-hidden-retries-nested-retry-ladder.md
Fixing #6027 (Dodo 429s escaping the checkout path as uncaught Convex errors), an
app-level bounded retry ladder was added around the @dodopayments/convex
component's checkout call. The component constructs its REST client with the
SDK's default retry policy, so the ladder silently nested over an invisible
second retry layer.
WORLDMONITOR-WP: Uncaught Error: Failed to create checkout session: 429 status code (no body) with a stack entirely inside
@dodopayments/convex / @dodopayments/core frames — no app frames.DODO_API_KEY bucket, and an outer wall-clock deadline that
could not bound an in-flight attempt (the SDK sleeps an uncapped Retry-After
inside one attempt).vi.mock of the provider module), so the SDK's internal
retries were invisible to the whole suite. Every in-process reviewer missed
it too; only a cross-model adversarial review pass (different model family,
separate process) caught the composition, via a fake-fetch repro showing one
adapter-level checkout performing three HTTP calls.Verified against the pinned source before acting ([email protected],
node_modules/dodopayments/client.js): maxRetries ?? 2 at construction,
shouldRetry returns true for status 429, and retryRequest honors
retry-after-ms / Retry-After verbatim with no cap ("If the API asks us to
wait a certain amount of time, just do what it says").
Fix (PR #6032):
convex/lib/dodo.ts buildCheckoutClientOptions() returns
{ maxRetries: 0, timeout: CHECKOUT_PROVIDER_ATTEMPT_TIMEOUT_MS }. A
no-network production-seam test mocks the SDK constructor, calls the real
createDodoCheckoutSession(), and asserts those options plus exactly one
checkoutSessions.create() call.
The component's checkout handler was a stateless proxy (it ignores ctx
entirely — zod validation + the same checkoutSessions.create call), so
nothing stateful was lost; webhooks verify separately via
@dodopayments/core and are untouched.convex/payments/checkoutRateLimit.ts,
runCheckoutWithRateLimitRetry) is now the ONLY retry layer: bounded
delays, +/-25% jitter, an 8s wall-clock budget that reserves the next
attempt's full timeout before admitting a retry, and a provider Retry-After
floor applied after jitter (never reduced by low jitter).error.status === 429 from the SDK's
APIError) instead of regex-only on the error message.Side effect worth knowing: removing the component from the path removed the component-level "Uncaught Error" Sentry signature entirely — an error thrown inside a Convex component's action is always reported as a failed component execution, even when the parent action catches it.
Exactly one layer owns retry policy. With the SDK pinned to zero retries and a per-attempt timeout, "one ladder attempt" means exactly one bounded HTTP request, so the ladder's attempt count, jitter, and wall-clock deadline are real invariants instead of multipliers over hidden behavior.
The repo already encoded this lesson for a different call site:
convex/payments/billing.ts renewal reconciliation constructs its client with
maxRetries: 0, with a comment warning that an SDK-honored Retry-After "could
sleep minutes." The checkout path just hadn't inherited the discipline because
the component hid the client construction.
maxRetries: 2, retry 429/408/409/5xx, and honor Retry-After verbatim.
Grep the vendored package for maxRetries, shouldRetry, retryRequest.maxRetries: 0 (plus a per-attempt timeout) wherever an app-level
ladder owns retries, and guard the production seam with a no-network test
that mocks the SDK constructor and calls the real wrapper (see
convex/__tests__/dodoCheckoutClient.test.ts) so a refactor or dependency
bump cannot silently reintroduce nested retries.