Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [8.2.0] - 2026-09-26

### Added

- **The client reads the rate-limit headers, and acts on them.** Both meters,
the per-minute REST one and the separate hourly history budget, on
`client.rateLimit`:

```js
tp.rateLimit.rest.remaining;
tp.rateLimit.history.remaining;
secondsUntilReset(tp.rateLimit.rest);
```

Every field can be null, and null means the server did not say rather than
"nothing left". Use `isExhausted`, true only when the server said zero. The
per-minute figures ride most responses; the hourly history ones are withheld
from anything a shared cache may store, because they are per-caller; an
unmetered plan advertises nothing. A response served from a cache is ignored
entirely, because its figures belong to whoever populated the entry. `reset` is a relative
countdown frozen when it was read, so `secondsUntilReset` ages it rather
than returning a stale number.

A window the server says is spent is now waited out instead of walked into,
since that request is a certain 429 that also spends budget being refused.
`retry: { respectRemaining: false }` opts out.

The hourly history budget is new on the wire; before it there was nothing to
read.

### Changed

- **Calls may now block before sending.** When the server has said your window
is spent, or has issued a 429 that is still in force, the client waits rather
than sending a request certain to be refused. A call that used to return in
200ms can now take up to `retry.maxRetryAfterMs` (120000) first. That is a
TOTAL across the call, not per wait: the shared 429 gate and the
spent-window wait stack, and before the budget existed a 429 carrying both a
`Retry-After` and a spent window blocked for 180 seconds under a 120 second
cap. Turn the two halves off with `retry: { respectRemaining: false }` and
`retry: { on429: false }`.

### Fixed

- **A paged history call crashed on the default configuration.** `#private`
fields on the transport failed their brand check through the caching Proxy,
so `history.days()` threw `TypeError: Receiver must be an instance of class
Transport` on page two for anyone who had not passed `cache: false`. Every
test passed `cache: false`, so none of them saw it.

- **`on429: false` did not opt out.** It threw the error the caller asked for
and then held their NEXT call for the full `Retry-After` anyway, because the
shared gate was closed regardless of the setting.

- **The gate timed off the wall clock.** A backward NTP step turned a
five-second wait into however far the clock moved, unbounded, because the cap
is applied when the gate is armed and not when it is served. It uses a
monotonic clock now, as the Python sibling always did.

- **A 429 was waited out once per in-flight request.** The wait belongs to the
caller, not to whichever request met it, so ten concurrent requests each
slept their own `Retry-After` and then retried at the same instant,
re-tripping the limit together. It is taken once now, on a gate shared by the
whole client, with a little jitter so waiters do not wake in unison. A
shorter wait arriving while a longer one is in force no longer brings the
gate forward.

## [8.1.0] - 2026-09-23

### Added
Expand Down
61 changes: 52 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ Jungle Cruise 40 min

`new ThemeParks(options)` takes the following keyword options:

| Option | Type | Default | Purpose |
| ----------- | ----------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `string` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
| `userAgent` | `string` | `themeparks-sdk-js/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
| `apiKey` | `string` | none | API key from api.themeparks.wiki, sent as `X-API-Key`. Optional; a key raises the limits. |
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. Useful for logging, mocking, or older runtimes. |
| `timeoutMs` | `number` | `10000` | Per-request timeout in milliseconds. |
| `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. |
| `cache` | `Cache \| false \| { maxEntries? }` | in-memory LRU | See [Caching](#caching) below. `false` disables caching entirely. |
| Option | Type | Default | Purpose |
| ----------- | ----------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `string` | `https://api.themeparks.wiki/v1` | API base URL (point at a mock / staging if you need to). |
| `userAgent` | `string` | `themeparks-sdk-js/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
| `apiKey` | `string` | none | API key from api.themeparks.wiki, sent as `X-API-Key`. Optional; a key raises the limits. |
| `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation. Useful for logging, mocking, or older runtimes. |
| `timeoutMs` | `number` | `10000` | Per-request timeout in milliseconds. |
| `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. |
| `cache` | `Cache \| false \| { maxEntries? }` | in-memory LRU | See [Caching](#caching) below. `false` disables caching entirely. |

Example:

Expand Down Expand Up @@ -161,6 +161,49 @@ const entries = await tp.entity(mk).schedule.range(new Date('2026-05-01'), new D
console.log(`${entries.length} schedule entries`);
```

## Rate limits

The API meters requests per minute, and history requests again per hour. Both
are read off every response that carries them:

```js
import { ThemeParks, isExhausted, secondsUntilReset } from 'themeparks';

const tp = new ThemeParks({ apiKey: KEY });
await tp.entity(parkId).live();

tp.rateLimit.rest.remaining; // 299
secondsUntilReset(tp.rateLimit.rest); // 40
tp.rateLimit.history.remaining; // on a history call
```

**`null` means the server did not say, never "nothing left".** Use
`isExhausted`, which is true only when the server actually said zero.

Which figures you get depends on the response:

- The **per-minute** figures ride most responses, anonymous ones included.
- The **hourly history** figures are withheld from anything a shared cache may
store, because they are per-caller and a cache would hand one caller's budget
to another. In practice you get them on calls made with a key.
- An **unmetered plan** advertises nothing at all.

A response served from a cache is ignored entirely. Its figures belong to
whoever populated the entry and its countdown is already wrong: a cached
`remaining: 0` would otherwise make the client sleep out someone else's
window.

The client acts on what it reads. When a response says the window is spent, the
next request waits for the advertised reset rather than sending one that is
certain to be refused, and to cost a unit of budget being refused. Opt out with
`retry: { respectRemaining: false }`.

**A 429 is held once for the whole client.** The wait belongs to the caller,
not to whichever request met it, so it goes on a shared gate with a little
jitter. Without that, ten concurrent requests each sleep their own copy of
`Retry-After` and then all retry at the same instant, re-tripping the limit
together.

## History

Three endpoints answer what an entity did in the past. Days are park-local; the
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "themeparks",
"version": "8.1.0",
"version": "8.2.0",
"description": "Official SDK for the ThemeParks.wiki API",
"license": "MIT",
"repository": "github:ThemeParks/ThemeParks_JavaScript",
Expand Down
24 changes: 23 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
type FetchLike,
type RetryConfig,
} from './transport';
import type { RateLimits } from './ratelimit';

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

export interface ThemeParksOptions {
Expand Down Expand Up @@ -51,6 +52,7 @@ export class ThemeParks {
max: options.retry?.max ?? 3,
on429: options.retry?.on429 ?? true,
maxRetryAfterMs: options.retry?.maxRetryAfterMs ?? DEFAULT_MAX_RETRY_AFTER_MS,
respectRemaining: options.retry?.respectRemaining ?? true,
},
fetch: fetchFn,
});
Expand All @@ -60,6 +62,26 @@ export class ThemeParks {
this.destinations = new DestinationsApi(this.raw);
}

/**
* What the server last said about your two budgets.
*
* `rateLimit.rest` is the per-minute REST meter; `rateLimit.history` is the
* separate hourly history budget. Every field can be null, because every
* field can legitimately be absent: an unmetered plan advertises nothing,
* and neither does a publicly cacheable response, since the figures are
* per-caller and a shared cache would hand one caller's to another.
*
* null therefore means "the server did not say", never "nothing left". Use
* `isExhausted`, which is true only when it said zero.
*
* Read from the inner transport rather than a copy, so a cache HIT -- which
* sends no request and so learns nothing -- correctly leaves the last known
* figures standing. A hit spent no budget either.
*/
get rateLimit(): RateLimits {
return this.transport.rateLimit;
}

entity(id: string): EntityHandle {
return new EntityHandle(this.raw, id);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ export {
type LiveDataEntry,
type LiveQueue,
} from './ergonomic/live';
export { isExhausted, secondsUntilReset, type RateLimit, type RateLimits } from './ratelimit';
export { parseApiDateTime } from './dates';
Loading
Loading