Skip to content

Read the rate-limit headers, and hold a 429 once per client - #27

Merged
cubehouse merged 4 commits into
mainfrom
feat/rate-limit-awareness
Sep 28, 2026
Merged

cubehouse merged 4 commits into
mainfrom
feat/rate-limit-awareness

Conversation

@cubehouse

Copy link
Copy Markdown
Member

The SDK read exactly one header, Retry-After, and only after a 429 had already happened. It could tell you that you had run out, never that you were about to.

Both meters, on the client

tp.rate_limit.rest.remaining              # 299
tp.rate_limit.history.remaining           # on a history call
tp.rate_limit.rest.seconds_until_reset()

The per-minute REST meter, and the hourly history budget the API started publishing today.

Absence is not zero, and the whole design turns on it

An unmetered plan advertises no figures, and neither does a publicly cacheable response, because the numbers are per-caller and a shared cache would hand one caller's budget to another. Anonymous calls therefore carry nothing.

So None means the server did not say. .exhausted is true only when it said zero. Reading an unknown as zero would stall every anonymous client permanently, and it is the first thing the tests pin.

reset is a relative countdown frozen when it was read, so seconds_until_reset() ages it. Using the raw value later is how a client waits an hour longer than it needs to — the same bug the server had in its cached headers.

It acts on what it reads

A window the server said is spent is waited out rather than walked into: that request is a certain 429 that also costs a unit of budget to be refused. RetryConfig(respect_remaining=False) opts out.

A 429 is now held once for the whole client

The wait belongs to the caller, not to whichever request met it. Ten concurrent requests each slept their own Retry-After and then retried at the same instant, re-tripping the limit together — a thundering herd the client inflicted on itself, and on us.

It now goes on a shared gate with a little jitter, taken once. A shorter wait arriving while a longer one is in force no longer brings the gate forward. Past max_retry_after the gate is deliberately left open: we raise instead, and blocking the caller's next call for most of an hour is the opposite of letting them checkpoint and resume.

Two things worth calling out

Production found a bug my tests could not. Caching is on by default and wraps the transport; every test passed cache=False, so the first real call raised AttributeError — the caching wrapper had no rate_limit to forward. Tests that all take the same non-default path cover a shape the users do not have. Fixed, and pinned by a test that fails if the forwarding is removed.

One mutation survived the first battery. Removing a prefix filter from the REST read changed no behaviour and no test. It was redundant: the lookups are by exact key, so RateLimit-Limit and RateLimit-History-Limit cannot collide. The dead guard is gone rather than the test being bent around it.

Verification

  • 210 tests, 23 new. Mutation-checked: treating an unknown remaining as exhausted fails 3, letting a blank response erase what we knew fails 1, bringing the gate forward on a shorter wait fails 1, removing the jitter fails 5.
  • Run against production: reads limit=300 remaining=299 reset=60 policy='300;w=60' on a real anonymous call, with the history meter correctly absent and exhausted=False.
  • ruff, mypy --strict and mkdocs build --strict clean.

JavaScript gets the same treatment next.

🤖 Generated with Claude Code

cubehouse and others added 4 commits September 24, 2026 17:25
The SDK read exactly one header, Retry-After, and only after a 429 had already
happened. It could tell you that you had run out, never that you were about to.

Both meters are now on the client. The per-minute REST one, and the hourly
history budget that tpwserver started publishing today:

    tp.rate_limit.rest.remaining
    tp.rate_limit.history.remaining
    tp.rate_limit.rest.seconds_until_reset()

ABSENCE IS NOT ZERO, and the whole design turns on it. An unmetered plan
advertises no figures, and neither does a publicly cacheable response, because
the numbers are per-caller and a shared cache would hand one caller's budget to
another -- so anonymous calls carry nothing. None therefore means the server
did not say. `.exhausted` is true only when it said zero. Reading an unknown as
zero would stall every anonymous client permanently, which is the first thing
the tests pin.

`reset` is a relative countdown frozen when it was read, so seconds_until_reset
ages it. Using the raw value later is how a client waits an hour longer than it
needs to, and it is the same bug the server had in its cached headers.

The client acts on what it reads: a window the server said is spent is waited
out rather than walked into, because that request is a certain 429 that also
costs a unit of budget to refuse. RetryConfig(respect_remaining=False) opts out.

A 429 IS NOW HELD ONCE FOR THE WHOLE CLIENT. The wait belongs to the caller,
not to whichever request met it. Ten concurrent requests each slept their own
Retry-After and then retried at the same instant, re-tripping the limit
together -- a thundering herd the client inflicted on itself and on us. It goes
on a shared gate with a little jitter, taken once. A shorter wait arriving
while a longer one is in force no longer brings the gate forward. Past
max_retry_after the gate is deliberately left open: we raise instead, and
blocking the next call for most of an hour is the opposite of letting the
caller checkpoint.

Found by running it against production rather than by reading: caching is ON by
default and wraps the transport, and every test used cache=False, so the first
real call raised AttributeError because the wrapper had no rate_limit to
forward. Tests that all take the same non-default path cover a shape the users
do not have. Fixed, and pinned.

One mutation survived the first battery: removing a prefix filter from the REST
read changed nothing. It was redundant -- the lookups are by exact key, so the
two meters cannot collide -- so the dead guard is gone rather than the test
being bent around it.

210 tests. ruff, mypy and mkdocs --strict clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…opt out

Four review agents. Three findings were real and two of them were mine.

THE PREMISE WAS BACKWARDS. Both the module docstring and the README said
anonymous calls carry no figures and keyed calls do. Measured against
production, it is the other way round for the per-minute meter: anonymous
responses DO carry RateLimit-*, and it is the hourly history set that the
server withholds from anything a shared cache may store. Corrected everywhere.

And the figures on a cached response are not ours. Three consecutive calls to
/live came back with age: 9 and an unmoving remaining: 285 -- one caller's
numbers, frozen when the entry was populated, served to everyone. A cached
remaining: 0 would make the client sleep out a window belonging to someone
else, which is the exact mistake this module exists to avoid. A response with a
non-zero Age is now treated as saying nothing at all. Confirmed against
production that a cache MISS carries no Age and is still recorded, so the
feature works and only declines what it should.

respect_429=False DID NOT OPT OUT. It raised the RateLimitError the caller
asked for and then closed the shared gate anyway, so their NEXT call blocked
for the full Retry-After with no way to stop it. The setting says do not wait
on a 429; the gate is a wait on a 429. An advertised switch that switches
nothing is worse than no switch.

Also:

- Integer parsing is strict in both SDKs now. Python's int() reads "1_0" as 10
  (PEP 515) and JavaScript's Number() read "0.4" as 0, which would make
  isExhausted true and sleep out a window the caller had not spent. Fifteen
  edge cases now agree byte for byte across the two.
- The two timing tests asserted ranges around real wall-clock time even though
  the method takes an injectable `now`. Under CPU contention that is a flaky
  gate test, and one did flake while both suites ran at once. They pass an
  explicit clock now and assert exact values.
- The README listed the read-only `rate_limit` property as a constructor
  option, and its retry default was missing respect_remaining.
- The CHANGELOG gains a Changed section saying plainly that calls may now block
  before sending, with both opt-outs named on the same line. It is the opt-outs
  that make this a MINOR rather than a MAJOR, so they have to work.

216 tests. ruff, mypy and mkdocs --strict clean. Mutation-checked: honouring
cached figures fails 1, and the opt-out fix is pinned both ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test-quality review built the harness mine should have had -- a fake sleep
that ADVANCES an injected clock -- and it changed a conclusion.

There are two self-initiated holds, the shared 429 gate and the spent-window
wait, and they stack. A 429 carrying both a Retry-After and
RateLimit-Remaining: 0 slept 5s at the gate, then 55s for the window, three
times over:

    sleeps: [5.2, 54.8, 5.2, 54.8, 5.1, 54.9]   TOTAL 180s, cap 120s

Every leg was under the cap, so the per-leg check never fired and the promise
the cap makes was reachable around. max_retry_after is a per-CALL budget now;
the same scenario blocks for exactly 120s, and 15s under a 30s cap.

Nothing caught it because no test in either file ever sent a 429 carrying
rate-limit headers -- both docstrings claimed to cover the gate and every
fixture was a 200 -- and because a fake sleep that never advances a clock
cannot show a cumulative total at all.

test_transport.py had ENCODED the bug: it asserted three sleeps of 3000s,
9000 seconds of blocking under a 3600s cap, and read as though that were the
point of the cap. Rewritten to assert the sum.

The async client had NO tests for any of this. The async caching wrapper's
rate_limit property, the async hold, and AsyncThemeParks.rate_limit were all
reachable only by reading -- and a missing wrapper property is the exact bug
that crashed the first real call against production on the sync side. Fixed in
both, tested in one. Now in both: 11 async tests.

Also: floating-point residue was emitting a trailing sleep of about 1e-14,
and a sub-millisecond wait is not a wait.

229 tests. ruff, mypy and mkdocs --strict clean. Mutation-checked: removing
the per-call budget fails 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The concurrency review measured the gate under real load. Its headline is
honest and I am recording it rather than the claim: when N requests are
ALREADY in flight the gate buys nothing (20 server hits either way, marginally
slower for the jitter). Its real value is holding requests not yet sent, and
there it halved refusals over a sustained run, 53 down to 26. The docstring
overstated the first case.

RETRY-AFTER: 0 TURNED THE CLIENT INTO A HAMMER. Only None reaches the
exponential backoff, so a header parsing to zero meant no wait at all -- and
`Retry-After: 0` is legal per RFC 9110, as is a negative, as is an
already-past date. Measured:

    Retry-After '0'  -> 4 hits in 3ms
    Retry-After '-5' -> 4 hits in 2ms
    absent           -> 4 hits in 1962ms   (correct)

Ten threads made that 204 requests a second at a server actively refusing
them. A non-positive wait is not a wait, and now says None.

Feeding straight into it: parsedate_to_datetime returns a NAIVE datetime for
the RFC 5322 `-0000` form, and .timestamp() read it as local time. On a host
an hour off UTC a 120-second wait came out as 0, landing in the spin above.
Now assumed UTC, and `-0000` and `GMT` agree.

THE SPENT-WINDOW PATH WAS A PURE HERD. Every waiter derived its deadline from
the same observed_at and slept to the same absolute instant with no spread at
all: measured 0ms across ten waiters in the JavaScript sibling, the tightest
burst in the client, on the very branch that exists to avoid a 429. It runs
through the same jitter as the gate now.

A WAITER THAT WOKE INTO A RE-CLOSED GATE SENT ANYWAY. _hold slept once and
returned, so a waiter that woke while someone else's 429 had pushed the gate
further out ignored what it already knew: measured waking at 584ms with the
gate shut for another two seconds. It re-reads now, but only when the deadline
actually MOVED -- looping unconditionally spins against any clock that does
not advance, which is every test harness.

My first spread test was vacuous in exactly the way the reviewers keep
finding: against a live clock `left` varies by itself, so the set was distinct
with or without spread and removing the spread passed. Frozen clock now, and
the mutation fails.

Not fixed, and worth a card: the jitter is a fixed 0.25s that does not scale
with the herd. Spreading N requests at R per second needs N/R seconds, so at
N=50 the constant is cosmetic. The right answer is a release slot derived from
the advertised limit and window, which the client already parses.

241 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cubehouse

Copy link
Copy Markdown
Member Author

Held for 28 September. Decided 2026-09-26: this ships with the paid plans going live, not before. Open until then for review — ThemeParks/programme#453 carries the release steps and the deferred items.

Not a blocker on anything: the server side (tpwserver#257) has been live since the 24th and nothing depends on clients reading these headers yet. If review turns something up on the day, dropping this from the launch costs nothing.

What changed since it was opened, and why it is worth a look

Four review agents found eleven defects that the passing suite did not. In rough order of how badly they would have bitten:

Paged history crashed for every default caller #private fields failed their brand check through the caching Proxy; every test passed cache: false (JS)
Retry-After: 0 made the client hammer 4 requests in 3ms, 204/s across ten threads, against a server actively refusing
max_retry_after bounded nothing 180s of blocking inside one call under a 120s cap; a test had encoded the bug
The documented premise was backwards anonymous responses DO carry the per-minute figures, and they come off a CDN — measured age: 9, frozen remaining: 285
Both opt-outs did not opt out you got the error you asked for, then your next call blocked
Two release herds the spent-window path had zero spread: ten waiters left in the same millisecond
Three tests could not fail including one my own monotonic-clock fix gutted

Every fix is mutation-checked and the commit messages carry the measurements. They are written to be read rather than skimmed.

Two things I would want a reviewer to push on

  1. The client now sleeps on its own initiative, up to 120s, silently. Two reviewers independently said that needs at least a log line before it is the default (ThemeParks/programme#442). I left it, because both opt-outs work — but it is the judgement call most worth disagreeing with.
  2. The gate is a real improvement and a partial one. Measured: refused requests halved over a sustained run, 53 → 26. But the release spread is a fixed 0.25s that does not scale, so at N=50 it is cosmetic (ThemeParks/programme#441). The docstring now records the measurement rather than the claim.

@cubehouse
cubehouse merged commit 9dbd917 into main Sep 28, 2026
6 checks passed
@cubehouse
cubehouse deleted the feat/rate-limit-awareness branch September 28, 2026 09:45
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.

1 participant