Skip to content

Commit f53397c

Browse files
cubehouseclaude
andauthored
Read the rate-limit headers, and hold a 429 once per client (#48)
* feat: read the rate-limit headers, and hold a 429 once per client Same treatment as the Python sibling, same reasoning. 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 the API started publishing today: tp.rateLimit.rest.remaining tp.rateLimit.history.remaining secondsUntilReset(tp.rateLimit.rest) ABSENCE IS NOT ZERO, and the 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. null means the server did not say. isExhausted 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 secondsUntilReset 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. retry: { respectRemaining: 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. It goes on a shared gate with a little jitter, taken once, and the retry path no longer pays it a second time. A shorter wait arriving while a longer one is in force no longer brings the gate forward. Past maxRetryAfterMs the gate is deliberately left open: we throw instead, and blocking the next call for most of an hour is the opposite of letting the caller checkpoint. The cache wrapper gap that Python hit does not exist here -- the client holds the inner transport directly -- but there is a test for it either way, because caching is on by default and every other test turns it off. Verified against production: reads limit=300 remaining=296 reset=40 policy="300;w=60" on a real anonymous call, with the history meter correctly absent and isExhausted false. 123 tests, 21 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 fails 1, removing the jitter fails 1, and not ageing the reset fails 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: paged history crashed on the default cache, plus three more Four review agents, two of them independently found the same blocker. PAGED HISTORY CRASHED FOR EVERY DEFAULT CALLER. I added #gate and #hold as ECMAScript private fields on Transport. client.ts wraps the transport in a Proxy to add caching, and a Proxy forwards methods with `this` bound to the proxy, so the private brand check fails: TypeError: Receiver must be an instance of class Transport history.days() threw on page two for anyone who had not passed cache: false -- the paid feature, on the default configuration. 130 tests passed because the paging test helper hardcodes cache: false, so every paging test took the one path where the bug cannot fire. They are TS-private now, which compiles to a plain property and forwards fine, and there is a paging test on the default cache that fails if the # fields come back. THE GATE TIMED OFF THE WALL CLOCK. closeFor stored Date.now() + ms and waitMs subtracted Date.now(), so a backward NTP step turned a five-second wait into however far the clock moved -- an hour, measured -- with nothing bounding it, because the cap is applied when the gate is armed and never when it is served. Monotonic now, which is what the Python sibling had from the start. on429: false DID NOT OPT OUT. It threw the error the caller asked for and then closed the shared gate anyway, so their NEXT call blocked for the full Retry-After. An advertised switch that switches nothing is worse than none. THE PREMISE WAS BACKWARDS. The docs said anonymous calls carry no figures. Measured against production it is the other way round for the per-minute meter; the hourly history set is the one withheld from cacheable responses. And a cached response's figures belong to whoever populated the entry -- age: 9 with an unmoving remaining: 285, served to everyone -- so a non-zero Age is now treated as saying nothing. A cache MISS carries no Age and is still recorded. Also: strict integer parsing, so "0.4" no longer reads as 0 and makes isExhausted true; fifteen edge cases now agree byte for byte with Python. Two timing tests made deterministic instead of asserting ranges around the real clock. Gate, readRateLimits and the UNKNOWN_* sentinels unexported -- Gate had no route to the client's instance and readRateLimits' parameter type was not exported, so nobody could name what they were passing. A CHANGELOG Changed section saying plainly that calls may now block before sending. 131 tests. tsc, eslint, prettier and the build clean. Mutation-checked: restoring the # fields fails the new paging test, honouring cached figures fails 1, and both opt-outs are pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: maxRetryAfterMs bounds the whole call, and three vacuous tests Same per-call budget as the Python sibling, same measurement behind it: the gate wait and the spent-window wait stack, so a 429 carrying both a Retry-After and RateLimit-Remaining: 0 blocked for 180 seconds under a 120 second cap. Every leg was under the cap, so the per-leg check never fired. maxRetryAfterMs is a per-call budget now. Three tests could not fail, all found by mutation: - The spent-window assertion was an upper bound only, on the single test covering the flagship behaviour. `sleep(left)` in place of `sleep(left * 1000)` -- seven milliseconds instead of seven seconds, a thousandfold too short -- passed green. Both bounds now. - The jitter test was sound against Date.now()'s millisecond granularity and MY monotonic-clock change gutted it: performance.now() ticks between the 20 calls, so the set is distinct with or without jitter. It asserted that time passes. It runs against a frozen clock now and bounds the spread. - Nothing proved a sleep was AWAITED rather than merely requested. Dropping every await in hold() passed all 135 tests. A fake sleep that resolves on a later macrotask and counts itself pending now fails if a request starts while a hold is in flight. Honest limit: dropping ONE await is still masked by the other, because with one await remaining the hold does still block. No test in either file had ever sent a 429 carrying rate-limit headers, which is why none of this surfaced: both docstrings claimed to cover the gate and every fixture was a 200. 136 tests. tsc, prettier and the build clean. Mutation-checked: removing the budget fails 1, deleting the jitter fails 2, the thousandfold-short sleep fails 1, dropping all awaits fails 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: Retry-After parsing, two release herds, and a NaN that disabled the gate Same three fixes as the Python sibling, same measurements behind them. RETRY-AFTER PARSING. Only null 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 and an already-past date. Measured in Python, which had the identical shape: four requests in 3ms against a server that had just said 429, and 204 a second across ten threads. Number() was also far too generous for a `delta-seconds = 1*DIGIT` field: it read ' ' as 0 and spun, and '0x10' as 16 and slept 48 seconds across three retries where Python correctly took 2. The two parsers in this file now follow the same rule, which they did not after the last round tightened only one. THE SPENT-WINDOW PATH WAS A PURE HERD: ten waiters left inside the SAME MILLISECOND, measured. Every one derived its deadline from the same observedAt and slept to the same absolute instant with no spread. It runs through the same jitter as the gate now. A WAITER THAT WOKE INTO A RE-CLOSED GATE SENT ANYWAY -- measured waking at 584ms with the gate shut for another two seconds. It re-reads now, but only when the deadline actually MOVED. My first attempt re-read unconditionally and spun 57 times against a frozen test clock, which is the correct behaviour of a wrong loop. A NaN jitter would have made setTimeout fire immediately and silently disable the gate rather than fail loudly. Guarded. The new spread test is measured against a frozen clock, because against a live one `left` varies by itself and the assertion passes with the spread deleted. 137 tests. Mutation-checked: removing either spread fails, and so does removing the budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2068594 commit f53397c

9 files changed

Lines changed: 1055 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [8.2.0] - 2026-09-26
9+
10+
### Added
11+
12+
- **The client reads the rate-limit headers, and acts on them.** Both meters,
13+
the per-minute REST one and the separate hourly history budget, on
14+
`client.rateLimit`:
15+
16+
```js
17+
tp.rateLimit.rest.remaining;
18+
tp.rateLimit.history.remaining;
19+
secondsUntilReset(tp.rateLimit.rest);
20+
```
21+
22+
Every field can be null, and null means the server did not say rather than
23+
"nothing left". Use `isExhausted`, true only when the server said zero. The
24+
per-minute figures ride most responses; the hourly history ones are withheld
25+
from anything a shared cache may store, because they are per-caller; an
26+
unmetered plan advertises nothing. A response served from a cache is ignored
27+
entirely, because its figures belong to whoever populated the entry. `reset` is a relative
28+
countdown frozen when it was read, so `secondsUntilReset` ages it rather
29+
than returning a stale number.
30+
31+
A window the server says is spent is now waited out instead of walked into,
32+
since that request is a certain 429 that also spends budget being refused.
33+
`retry: { respectRemaining: false }` opts out.
34+
35+
The hourly history budget is new on the wire; before it there was nothing to
36+
read.
37+
38+
### Changed
39+
40+
- **Calls may now block before sending.** When the server has said your window
41+
is spent, or has issued a 429 that is still in force, the client waits rather
42+
than sending a request certain to be refused. A call that used to return in
43+
200ms can now take up to `retry.maxRetryAfterMs` (120000) first. That is a
44+
TOTAL across the call, not per wait: the shared 429 gate and the
45+
spent-window wait stack, and before the budget existed a 429 carrying both a
46+
`Retry-After` and a spent window blocked for 180 seconds under a 120 second
47+
cap. Turn the two halves off with `retry: { respectRemaining: false }` and
48+
`retry: { on429: false }`.
49+
50+
### Fixed
51+
52+
- **A paged history call crashed on the default configuration.** `#private`
53+
fields on the transport failed their brand check through the caching Proxy,
54+
so `history.days()` threw `TypeError: Receiver must be an instance of class
55+
Transport` on page two for anyone who had not passed `cache: false`. Every
56+
test passed `cache: false`, so none of them saw it.
57+
58+
- **`on429: false` did not opt out.** It threw the error the caller asked for
59+
and then held their NEXT call for the full `Retry-After` anyway, because the
60+
shared gate was closed regardless of the setting.
61+
62+
- **The gate timed off the wall clock.** A backward NTP step turned a
63+
five-second wait into however far the clock moved, unbounded, because the cap
64+
is applied when the gate is armed and not when it is served. It uses a
65+
monotonic clock now, as the Python sibling always did.
66+
67+
- **A 429 was waited out once per in-flight request.** The wait belongs to the
68+
caller, not to whichever request met it, so ten concurrent requests each
69+
slept their own `Retry-After` and then retried at the same instant,
70+
re-tripping the limit together. It is taken once now, on a gate shared by the
71+
whole client, with a little jitter so waiters do not wake in unison. A
72+
shorter wait arriving while a longer one is in force no longer brings the
73+
gate forward.
74+
875
## [8.1.0] - 2026-09-23
976

1077
### Added

‎README.md‎

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ Jungle Cruise 40 min
4949
5050
`new ThemeParks(options)` takes the following keyword options:
5151
52-
| Option | Type | Default | Purpose |
53-
| ----------- | ----------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54-
| `baseUrl` | `string` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
55-
| `userAgent` | `string` | `themeparks-sdk-js/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
56-
| `apiKey` | `string` | none | API key from api.themeparks.wiki, sent as `X-API-Key`. Optional; a key raises the limits. |
57-
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. Useful for logging, mocking, or older runtimes. |
58-
| `timeoutMs` | `number` | `10000` | Per-request timeout in milliseconds. |
59-
| `retry` | `Partial<RetryConfig>` | `{ max: 3, on429: true, maxRetryAfterMs: 120000 }` | Retry/backoff behavior. `max` counts retries **beyond** the initial attempt (so `3` = up to 4 total). `maxRetryAfterMs` is the longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. |
60-
| `cache` | `Cache \| false \| { maxEntries? }` | in-memory LRU | See [Caching](#caching) below. `false` disables caching entirely. |
52+
| Option | Type | Default | Purpose |
53+
| ----------- | ----------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54+
| `baseUrl` | `string` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
55+
| `userAgent` | `string` | `themeparks-sdk-js/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
56+
| `apiKey` | `string` | none | API key from api.themeparks.wiki, sent as `X-API-Key`. Optional; a key raises the limits. |
57+
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. Useful for logging, mocking, or older runtimes. |
58+
| `timeoutMs` | `number` | `10000` | Per-request timeout in milliseconds. |
59+
| `retry` | `Partial<RetryConfig>` | `{ max: 3, on429: true, maxRetryAfterMs: 120000, respectRemaining: true }` | Retry/backoff behavior. `max` counts retries **beyond** the initial attempt (so `3` = up to 4 total). `maxRetryAfterMs` is the longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. |
60+
| `cache` | `Cache \| false \| { maxEntries? }` | in-memory LRU | See [Caching](#caching) below. `false` disables caching entirely. |
6161
6262
Example:
6363
@@ -161,6 +161,49 @@ const entries = await tp.entity(mk).schedule.range(new Date('2026-05-01'), new D
161161
console.log(`${entries.length} schedule entries`);
162162
```
163163
164+
## Rate limits
165+
166+
The API meters requests per minute, and history requests again per hour. Both
167+
are read off every response that carries them:
168+
169+
```js
170+
import { ThemeParks, isExhausted, secondsUntilReset } from 'themeparks';
171+
172+
const tp = new ThemeParks({ apiKey: KEY });
173+
await tp.entity(parkId).live();
174+
175+
tp.rateLimit.rest.remaining; // 299
176+
secondsUntilReset(tp.rateLimit.rest); // 40
177+
tp.rateLimit.history.remaining; // on a history call
178+
```
179+
180+
**`null` means the server did not say, never "nothing left".** Use
181+
`isExhausted`, which is true only when the server actually said zero.
182+
183+
Which figures you get depends on the response:
184+
185+
- The **per-minute** figures ride most responses, anonymous ones included.
186+
- The **hourly history** figures are withheld from anything a shared cache may
187+
store, because they are per-caller and a cache would hand one caller's budget
188+
to another. In practice you get them on calls made with a key.
189+
- An **unmetered plan** advertises nothing at all.
190+
191+
A response served from a cache is ignored entirely. Its figures belong to
192+
whoever populated the entry and its countdown is already wrong: a cached
193+
`remaining: 0` would otherwise make the client sleep out someone else's
194+
window.
195+
196+
The client acts on what it reads. When a response says the window is spent, the
197+
next request waits for the advertised reset rather than sending one that is
198+
certain to be refused, and to cost a unit of budget being refused. Opt out with
199+
`retry: { respectRemaining: false }`.
200+
201+
**A 429 is held once for the whole client.** The wait belongs to the caller,
202+
not to whichever request met it, so it goes on a shared gate with a little
203+
jitter. Without that, ten concurrent requests each sleep their own copy of
204+
`Retry-After` and then all retry at the same instant, re-tripping the limit
205+
together.
206+
164207
## History
165208
166209
Three endpoints answer what an entity did in the past. Days are park-local; the

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "themeparks",
3-
"version": "8.1.0",
3+
"version": "8.2.0",
44
"description": "Official SDK for the ThemeParks.wiki API",
55
"license": "MIT",
66
"repository": "github:ThemeParks/ThemeParks_JavaScript",

‎src/client.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
type FetchLike,
99
type RetryConfig,
1010
} from './transport';
11+
import type { RateLimits } from './ratelimit';
1112

1213
const DEFAULT_BASE_URL = 'https://api.themeparks.wiki/v1';
13-
const PACKAGE_VERSION = '8.1.0';
14+
const PACKAGE_VERSION = '8.2.0';
1415
const DEFAULT_USER_AGENT = `themeparks-sdk-js/${PACKAGE_VERSION}`;
1516

1617
export interface ThemeParksOptions {
@@ -51,6 +52,7 @@ export class ThemeParks {
5152
max: options.retry?.max ?? 3,
5253
on429: options.retry?.on429 ?? true,
5354
maxRetryAfterMs: options.retry?.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS,
55+
respectRemaining: options.retry?.respectRemaining ?? true,
5456
},
5557
fetch: fetchFn,
5658
});
@@ -60,6 +62,26 @@ export class ThemeParks {
6062
this.destinations = new DestinationsApi(this.raw);
6163
}
6264

65+
/**
66+
* What the server last said about your two budgets.
67+
*
68+
* `rateLimit.rest` is the per-minute REST meter; `rateLimit.history` is the
69+
* separate hourly history budget. Every field can be null, because every
70+
* field can legitimately be absent: an unmetered plan advertises nothing,
71+
* and neither does a publicly cacheable response, since the figures are
72+
* per-caller and a shared cache would hand one caller's to another.
73+
*
74+
* null therefore means "the server did not say", never "nothing left". Use
75+
* `isExhausted`, which is true only when it said zero.
76+
*
77+
* Read from the inner transport rather than a copy, so a cache HIT -- which
78+
* sends no request and so learns nothing -- correctly leaves the last known
79+
* figures standing. A hit spent no budget either.
80+
*/
81+
get rateLimit(): RateLimits {
82+
return this.transport.rateLimit;
83+
}
84+
6385
entity(id: string): EntityHandle {
6486
return new EntityHandle(this.raw, id);
6587
}

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,5 @@ export {
3939
type LiveDataEntry,
4040
type LiveQueue,
4141
} from './ergonomic/live';
42+
export { isExhausted, secondsUntilReset, type RateLimit, type RateLimits } from './ratelimit';
4243
export { parseApiDateTime } from './dates';

0 commit comments

Comments
 (0)