feat: add 429 / rate-limit retry with header-aware backoff#5
Conversation
Composes cleanly with the existing 401 session-refresh retry via a new private fetchWithRetry helper shared by request() and multipartRequest(). Three retries by default, configurable. Header priority in the new exported computeBackoffMs helper: 1. X-Rate-Limit-Reset (seconds) — Tripletex's documented header 2. Retry-After (seconds) — HTTP standard fallback 3. Exponential default: initialBackoffMs * (attempt + 1) Retry exhaustion throws the new TripletexRateLimitError (extends TripletexApiError) carrying retryAfterMs so callers can surface a meaningful delay. New public API (all backward-compatible): - TripletexRateLimitError - computeBackoffMs(attempt, headers, initialBackoffMs) - TripletexClientOptions: maxRetries, initialBackoffMs, fetch, sleep (fetch/sleep are advanced — used for test transport injection) Tests: adds tests/helpers/mock-fetch.ts (deterministic fake fetch + sleep recorder, no global stubs), tests/tripletex-client/rate-limit.test.ts (18 scenarios incl. header priority, retry budget, 401+429 composition, exhaustion), and tests/tripletex-client/basics.test.ts (18 tests locking down existing session / 401-retry / body-parse behavior so future refactors don't regress silently). 183 tests pass (147 from earlier + 36 new). Typecheck and build clean.
|
@afaqmnsr — beautiful work. The single shared Header priority order is exactly right ( Bonus Merging now. For the next one, I'd love #3 (parse Thanks again — this is a real upgrade. |
Summary
In the #4 review you flagged 429 / rate-limit handling as the next thing to tackle — "that's the one that bites people in production and I'd really appreciate you tackling it next" — so here it is. This PR adds header-aware 429 retry with exponential fallback to
TripletexClient, composed cleanly with the existing 401 session-refresh retry, plus a newtests/helpers/mock-fetch.tstransport harness and dedicated Vitest coverage for both the new rate-limit logic and the pre-existing client behavior.Design
The retry loop lives in one private helper —
fetchWithRetry(method, path, buildRequest)— whichrequest()andmultipartRequest()both delegate to.buildRequestis a closure the caller supplies so the session token, URL, headers, and body are re-resolved on every attempt (important: after a 401 the session is cleared, so the next attempt must re-mint the token).401 check runs before 429 check. Rationale: a 401 means the session is dead. Sleeping and re-sending the same request is guaranteed to fail. One 401 triggers a
this.session = null+ immediate re-attempt (samesessionRefreshedflag as before — still single-shot). Only if the next response is 429 do we enter the backoff branch. The two retries are independent counters, so a 401 followed by a chain of 429s still gets the fullmaxRetriesbudget for the rate-limit side.Header priority in
computeBackoffMs(attempt, headers, initialBackoffMs):X-Rate-Limit-Reset(seconds) — Tripletex's preferred header. Their OpenAPI spec'sinfo.descriptiondocuments theX-Rate-Limit-Limit/X-Rate-Limit-Remaining/X-Rate-Limit-Resettriple as the rate-limit contract, so we honor the vendor signal first.Retry-After(seconds) — HTTP standard fallback for servers/edge proxies that emit it instead.initialBackoffMs * (attempt + 1)— covers servers that emit neither (e.g. a misconfigured proxy, or synthetic 429s from a load balancer).Values are parsed with
Number(...)+Number.isNaNguards and clamped to>= 0, so garbage headers fall through to the next tier rather than producingNaNsleeps.computeBackoffMsis exported as a pure function so it can be unit-tested directly without spinning up a client.On retry exhaustion the loop throws
TripletexRateLimitError(a subclass ofTripletexApiError) carrying the finalretryAfterMsso callers can surface a meaningful delay to the user or a queue system.What's in this PR
Source
src/tripletex-client.ts— adds thefetchWithRetryprivate helper and routes bothrequest()andmultipartRequest()through it. Adds four optionalTripletexClientOptionsfields (maxRetries,initialBackoffMs,fetch,sleep). Adds the exportedTripletexRateLimitErrorsubclass and the exported purecomputeBackoffMshelper. TheisRetryparameter onrequest()/multipartRequest()is gone — it was never reachable from outside the class, but see the reviewer notes below.Tests
tests/helpers/mock-fetch.tsmockFetch(responses)andmockSleep()— lightweight, per-test transport injection. No global stubs, novi.mock. Establishes the pattern for all future client-layer tests.tests/tripletex-client/rate-limit.test.tsX-Rate-Limit-Resetwins overRetry-Afterwins over fallback), malformed-header degradation, retry exhaustion throwsTripletexRateLimitErrorwith correctretryAfterMs, 401 + 429 composition (401 refreshes session, subsequent 429s consume the retry budget independently), regression checks that non-429 responses still return normally and non-401/429 errors still surface asTripletexApiError. Pure unit tests viamockFetch/mockSleep— no wall-clock waits.tests/tripletex-client/basics.test.ts204, non-JSON text fallback), each HTTP method helper (get/post/put/delete/postMultipart) forwards correctly, error surface (status +bodyTextpreserved onTripletexApiError). Not strictly required for 429, but pairs naturally with the refactor — see notes.New public API surface
All additions are backward-compatible — existing callers need zero changes. Defaults match the previous behavior for non-429 paths.
TripletexClientOptions.maxRetries3TripletexClientOptions.initialBackoffMs1000TripletexClientOptions.fetchglobalThis.fetchTripletexClientOptions.sleepsetTimeout-basedTripletexRateLimitErrorTripletexApiError. CarriesretryAfterMs: number.computeBackoffMs(attempt, headers, initialBackoffMs)Test plan
npm run typecheck— clean (covers src + tests)npm test— green, no wall-clock waits in the new client testsnpm run build— clean, no change todist/shapenpm cifrom committedpackage-lock.json— reproducible installbasics.test.ts)fetch, no network, no fs writesTests added
tests/tripletex-client/rate-limit.test.tsX-Rate-Limit-ResetbeatsRetry-After,Retry-Afterbeats fallback, malformed header falls through to fallback, retry exhaustion throwsTripletexRateLimitError, 401 then 429 composes correctly, non-429 error still throwsTripletexApiError, non-retry responses still return on first attempttests/tripletex-client/basics.test.tsTripletexApiErrorpreserves status andbodyTexttests/helpers/mock-fetch.tsmockSleepreturns the requested delay without waitingNotes for reviewer
fetchWithRetryis intentionally private. It's an implementation detail of howrequestandmultipartRequestshare a retry loop, not a user-facing extension point. Keeping it private means we can change the signature (e.g. add header-based hints, merge 401 and 429 counters, swap to a different loop shape) without a major version bump. If you'd prefer it exposed for composition, happy to flip it.fetchandsleepoptions are "advanced". They exist primarily so tests can injectmockFetch/mockSleepwithout polluting globals — not because general users should override them. JSDoc marks them as such. If that framing feels off, happy to rename or fold them behind a debug-only symbol.sessionRefreshedboolean vsattemptcounter) so a request that hits 401 then a chain of 429s still gets the full rate-limit retry budget.basics.test.tsis a bonus. Not strictly required for this PR — the 429 logic could be reviewed without it — but since I was already addingmock-fetch.ts, locking down the existing session/401/body-parse behavior costs little and gives us regression coverage for future client refactors. Happy to split it into a separate PR if you'd prefer a tighter diff here.isRetryparameter removed fromrequest/multipartRequest. It was internal-only (nothing outside the class ever passed it, nothing intripletex-tools.tsforwards it) andfetchWithRetrynow owns that state via its own closure. Calling it out explicitly because a signature trim, however internal, deserves a line in the PR body.Follow-up PRs still queued
Same list as PR #4, status updated — no pressure, no timeline, per your guidance:
429 / rate-limit backoff— this PR.countparameter at 1000 — small Zod tweak across list tools.validationMessageson 4xx responses and surface them onTripletexApiError— makes the README's "LLM self-correction" story real.Dateobjects rather thanyyyy-MM-ddstrings.CHANGELOG.md— derived from git history once a couple more PRs have landed.Happy to squash, split, or reorder — whichever fits your review flow.
Thanks
Appreciate the quick turnaround on #4 — made it easy to keep momentum on this one.