Skip to content

feat: add 429 / rate-limit retry with header-aware backoff#5

Merged
cwvedvik merged 1 commit into
cwvedvik:mainfrom
afaqmnsr:add-429-rate-limit-backoff
Apr 27, 2026
Merged

feat: add 429 / rate-limit retry with header-aware backoff#5
cwvedvik merged 1 commit into
cwvedvik:mainfrom
afaqmnsr:add-429-rate-limit-backoff

Conversation

@afaqmnsr
Copy link
Copy Markdown
Contributor

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 new tests/helpers/mock-fetch.ts transport 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) — which request() and multipartRequest() both delegate to. buildRequest is 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 (same sessionRefreshed flag 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 full maxRetries budget for the rate-limit side.

Header priority in computeBackoffMs(attempt, headers, initialBackoffMs):

  1. X-Rate-Limit-Reset (seconds) — Tripletex's preferred header. Their OpenAPI spec's info.description documents the X-Rate-Limit-Limit / X-Rate-Limit-Remaining / X-Rate-Limit-Reset triple as the rate-limit contract, so we honor the vendor signal first.
  2. Retry-After (seconds) — HTTP standard fallback for servers/edge proxies that emit it instead.
  3. Exponential fallback 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.isNaN guards and clamped to >= 0, so garbage headers fall through to the next tier rather than producing NaN sleeps. computeBackoffMs is 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 of TripletexApiError) carrying the final retryAfterMs so callers can surface a meaningful delay to the user or a queue system.

What's in this PR

Source

  • src/tripletex-client.ts — adds the fetchWithRetry private helper and routes both request() and multipartRequest() through it. Adds four optional TripletexClientOptions fields (maxRetries, initialBackoffMs, fetch, sleep). Adds the exported TripletexRateLimitError subclass and the exported pure computeBackoffMs helper. The isRetry parameter on request() / multipartRequest() is gone — it was never reachable from outside the class, but see the reviewer notes below.

Tests

File Covers
tests/helpers/mock-fetch.ts mockFetch(responses) and mockSleep() — lightweight, per-test transport injection. No global stubs, no vi.mock. Establishes the pattern for all future client-layer tests.
tests/tripletex-client/rate-limit.test.ts Single-retry happy path, multi-retry, header priority (X-Rate-Limit-Reset wins over Retry-After wins over fallback), malformed-header degradation, retry exhaustion throws TripletexRateLimitError with correct retryAfterMs, 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 as TripletexApiError. Pure unit tests via mockFetch / mockSleep — no wall-clock waits.
tests/tripletex-client/basics.test.ts Locks down existing client behavior: session lifecycle (lazy create, reuse, expiry-triggered recreate), the existing 401 retry path, response body parsing (JSON, empty 204, non-JSON text fallback), each HTTP method helper (get / post / put / delete / postMultipart) forwards correctly, error surface (status + bodyText preserved on TripletexApiError). 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.

Addition Kind Default Notes
TripletexClientOptions.maxRetries option 3 Max 429 retries per request.
TripletexClientOptions.initialBackoffMs option 1000 Fallback exponential base when no rate-limit header is present.
TripletexClientOptions.fetch option globalThis.fetch Transport injection. Advanced / testability.
TripletexClientOptions.sleep option setTimeout-based Sleep injection. Advanced / testability.
TripletexRateLimitError exported class Extends TripletexApiError. Carries retryAfterMs: number.
computeBackoffMs(attempt, headers, initialBackoffMs) exported function Pure. Independently unit-testable.

Test plan

  • npm run typecheck — clean (covers src + tests)
  • npm test — green, no wall-clock waits in the new client tests
  • npm run build — clean, no change to dist/ shape
  • npm ci from committed package-lock.json — reproducible install
  • No change to existing 401 retry semantics (verified by basics.test.ts)
  • No mocks of global fetch, no network, no fs writes

Tests added

File Scenarios
tests/tripletex-client/rate-limit.test.ts single 429 then success, multiple 429s within budget, X-Rate-Limit-Reset beats Retry-After, Retry-After beats fallback, malformed header falls through to fallback, retry exhaustion throws TripletexRateLimitError, 401 then 429 composes correctly, non-429 error still throws TripletexApiError, non-retry responses still return on first attempt
tests/tripletex-client/basics.test.ts session lazy-create + reuse, session recreate on expiry, 401 triggers single session refresh + retry, JSON / empty / non-JSON body parsing, each HTTP helper forwards method + params + body, TripletexApiError preserves status and bodyText
tests/helpers/mock-fetch.ts (support) deterministic response queueing, call inspection, mockSleep returns the requested delay without waiting

Notes for reviewer

  • fetchWithRetry is intentionally private. It's an implementation detail of how request and multipartRequest share 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.
  • fetch and sleep options are "advanced". They exist primarily so tests can inject mockFetch / mockSleep without 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.
  • Composition order: 401 first, then 429. A 401 means the session token is dead; waiting for a rate-limit window before re-minting it would be pure waste. The two retries use separate state (sessionRefreshed boolean vs attempt counter) so a request that hits 401 then a chain of 429s still gets the full rate-limit retry budget.
  • basics.test.ts is a bonus. Not strictly required for this PR — the 429 logic could be reviewed without it — but since I was already adding mock-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.
  • isRetry parameter removed from request / multipartRequest. It was internal-only (nothing outside the class ever passed it, nothing in tripletex-tools.ts forwards it) and fetchWithRetry now 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:

  1. 429 / rate-limit backoff — this PR.
  2. Cap count parameter at 1000 — small Zod tweak across list tools.
  3. Parse validationMessages on 4xx responses and surface them on TripletexApiError — makes the README's "LLM self-correction" story real.
  4. Session-expiry timezone safety: compare Date objects rather than yyyy-MM-dd strings.
  5. Concurrent-401-retry dedup: promise-based refresh lock so two parallel requests hitting 401 don't double-refresh.
  6. 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.

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.
@cwvedvik
Copy link
Copy Markdown
Owner

@afaqmnsr — beautiful work. The single shared fetchWithRetry helper is a nice refactor on top of just adding the 429 handling — the old duplicated 401-retry blocks always bothered me a little. Glad to have them consolidated.

Header priority order is exactly right (X-Rate-Limit-Reset first matches what Tripletex actually emits), the malformed-header degradation is the kind of detail that quietly saves people in production, and the mockFetch / mockSleep injection pattern is going to be useful for everything else we add to the client later.

Bonus basics.test.ts is very welcome too — keep it in this PR, no need to split.

Merging now.

For the next one, I'd love #3 (parse validationMessages on 4xx and surface them on TripletexApiError) — that's the one that would actually deliver on the README's "LLM self-correction" promise and make day-to-day debugging much nicer. Whenever you have the bandwidth.

Thanks again — this is a real upgrade.

@cwvedvik cwvedvik merged commit 3b88f16 into cwvedvik:main Apr 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants