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
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# Changelog

## [3.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, are exposed
on `client.rate_limit`:

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

Every field is optional, and `None` means the server did not say rather than
"nothing left". Use `.exhausted`, which is 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 `seconds_until_reset()` ages
it rather than returning a stale number.

When a response says the window is spent, the next request now waits for the
advertised reset instead of sending one that is certain to be refused, and to
spend a unit of budget being refused. `RetryConfig(respect_remaining=False)`
turns it off.

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 that is certain to be refused. A call that used to
return in 200ms can now take up to `retry.max_retry_after` (120s) 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 `RetryConfig(respect_remaining=False)` and
`RetryConfig(respect_429=False)`.

### Fixed

- **`respect_429=False` did not opt out.** It raised 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.

- **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 now taken once, on a gate shared by the
whole client, with a little jitter so the waiters do not wake in unison. A
shorter wait arriving while a longer one is in force no longer brings the
gate forward.

## [3.1.0] - 2026-09-23

### Added
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. |
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True, max_retry_after=120.0)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). `max_retry_after` is the longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. |
| `retry` | `RetryConfig \| None` | `RetryConfig(max_retries=3, respect_429=True, max_retry_after=120.0, respect_remaining=True)` | Retry/backoff behavior. `max_retries` is N retries beyond the first attempt (so N+1 total calls). `max_retry_after` is the TOTAL the client will block for within one call, across both the shared 429 gate and any spent-window wait. Past a single `Retry-After` that long you get `RateLimitError` instead of a silent wait. |
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |

Example:
Expand Down Expand Up @@ -216,6 +216,50 @@ remaining keys are whatever fields that variant carries.
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
inputs.

## Rate limits

`client.rate_limit` is a read-only property, not a constructor option.

The API meters requests per minute, and history requests again per hour. Both
are advertised on every response that can carry them, and the client reads
them:

```python
with ThemeParks(api_key=KEY) as tp:
tp.entity(park_id).live()

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

**`None` means the server did not say, never "nothing left".** Use
`.exhausted`, 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 also acts on what it reads. When a response says the window is
spent, the next request waits for the advertised reset rather than sending a
request that is certain to be refused, and to cost a unit of budget being
refused. Turn that off with `RetryConfig(respect_remaining=False)`.

**A 429 is held once for the whole client.** The wait belongs to the caller,
not to whichever request happened to meet 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

`tp.entity(id).history` reads the archive. Both methods page for you and yield
Expand Down
17 changes: 17 additions & 0 deletions docs/api/ratelimits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Rate limits

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

`None` means the server did not say, never "nothing left". 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; calls made with a key do.

::: themeparks.RateLimits
options:
heading_level: 2

::: themeparks.RateLimit
options:
heading_level: 2
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ nav:
- Client: api/client.md
- Entity: api/entity.md
- History: api/history.md
- Rate limits: api/ratelimits.md
- Destinations: api/destinations.md
- Raw client: api/raw.md
- Helpers: api/helpers.md
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "themeparks"
version = "3.1.0"
version = "3.2.0"
description = "Official SDK for the ThemeParks.wiki API"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
6 changes: 5 additions & 1 deletion tests/unit/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,8 @@ def test_an_ordinary_rest_429_is_still_ridden_out(self):
with pytest.raises(RateLimitError) as caught:
list(tp.entity("park-1").history.days("2026-09-01", "2026-09-02"))
assert not isinstance(caught.value, BudgetExhaustedError)
assert slept == [2.0, 2.0, 2.0]
# Jittered: the gate is shared, so without a little spread every
# waiter would wake at the same instant and re-trip the limit
# together. One wait per retry, taken once, never doubled.
assert len(slept) == 3
assert all(2.0 <= s < 2.3 for s in slept), slept
Loading
Loading