Read the rate-limit headers, and hold a 429 once per client - #27
Merged
Merged
Conversation
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>
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 — Not a blocker on anything: the server side ( What changed since it was opened, and why it is worth a lookFour review agents found eleven defects that the passing suite did not. In rough order of how badly they would have bitten:
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
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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
Nonemeans the server did not say..exhaustedis 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.resetis a relative countdown frozen when it was read, soseconds_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-Afterand 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_afterthe 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 raisedAttributeError— the caching wrapper had norate_limitto 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-LimitandRateLimit-History-Limitcannot collide. The dead guard is gone rather than the test being bent around it.Verification
limit=300 remaining=299 reset=60 policy='300;w=60'on a real anonymous call, with the history meter correctly absent andexhausted=False.ruff,mypy --strictandmkdocs build --strictclean.JavaScript gets the same treatment next.
🤖 Generated with Claude Code