themeparks 3.1.0__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {themeparks-3.1.0 → themeparks-3.2.0}/CHANGELOG.md +57 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/PKG-INFO +46 -2
- {themeparks-3.1.0 → themeparks-3.2.0}/README.md +45 -1
- {themeparks-3.1.0 → themeparks-3.2.0}/pyproject.toml +1 -1
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/__init__.py +3 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_client.py +57 -0
- themeparks-3.2.0/themeparks/_ratelimit.py +182 -0
- themeparks-3.2.0/themeparks/_transport.py +449 -0
- themeparks-3.1.0/themeparks/_transport.py +0 -252
- {themeparks-3.1.0 → themeparks-3.2.0}/.gitignore +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/LICENSE +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/MIGRATION.md +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_cache.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/__init__.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/dates.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/destinations.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/entity.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/history.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/live.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_errors.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_generated/__init__.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_generated/models.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_raw.py +0 -0
- {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/py.typed +0 -0
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.2.0] - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **The client reads the rate-limit headers, and acts on them.** Both meters,
|
|
8
|
+
the per-minute REST one and the separate hourly history budget, are exposed
|
|
9
|
+
on `client.rate_limit`:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
tp.rate_limit.rest.remaining # 299
|
|
13
|
+
tp.rate_limit.history.remaining # on a history call
|
|
14
|
+
tp.rate_limit.rest.seconds_until_reset()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every field is optional, and `None` means the server did not say rather than
|
|
18
|
+
"nothing left". Use `.exhausted`, which is true only when the server said
|
|
19
|
+
zero. The per-minute figures ride most responses; the hourly history ones are
|
|
20
|
+
withheld from anything a shared cache may store, because they are per-caller;
|
|
21
|
+
an unmetered plan advertises nothing. A response served from a cache is
|
|
22
|
+
ignored entirely, because its figures belong to whoever populated the entry. `reset` is a
|
|
23
|
+
relative countdown frozen when it was read, so `seconds_until_reset()` ages
|
|
24
|
+
it rather than returning a stale number.
|
|
25
|
+
|
|
26
|
+
When a response says the window is spent, the next request now waits for the
|
|
27
|
+
advertised reset instead of sending one that is certain to be refused, and to
|
|
28
|
+
spend a unit of budget being refused. `RetryConfig(respect_remaining=False)`
|
|
29
|
+
turns it off.
|
|
30
|
+
|
|
31
|
+
The hourly history budget is new on the wire; before it there was nothing to
|
|
32
|
+
read.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Calls may now block before sending.** When the server has said your window
|
|
37
|
+
is spent, or has issued a 429 that is still in force, the client waits rather
|
|
38
|
+
than sending a request that is certain to be refused. A call that used to
|
|
39
|
+
return in 200ms can now take up to `retry.max_retry_after` (120s) first. That
|
|
40
|
+
is a TOTAL across the call, not per wait: the shared 429 gate and the
|
|
41
|
+
spent-window wait stack, and before the budget existed a 429 carrying both a
|
|
42
|
+
`Retry-After` and a spent window blocked for 180 seconds under a 120 second
|
|
43
|
+
cap. Turn the two halves off with `RetryConfig(respect_remaining=False)` and
|
|
44
|
+
`RetryConfig(respect_429=False)`.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`respect_429=False` did not opt out.** It raised the error the caller asked
|
|
49
|
+
for and then held their NEXT call for the full `Retry-After` anyway, because
|
|
50
|
+
the shared gate was closed regardless of the setting.
|
|
51
|
+
|
|
52
|
+
- **A 429 was waited out once per in-flight request.** The wait belongs to the
|
|
53
|
+
caller, not to whichever request met it, so ten concurrent requests each
|
|
54
|
+
slept their own `Retry-After` and then retried at the same instant,
|
|
55
|
+
re-tripping the limit together. It is now taken once, on a gate shared by the
|
|
56
|
+
whole client, with a little jitter so the waiters do not wake in unison. A
|
|
57
|
+
shorter wait arriving while a longer one is in force no longer brings the
|
|
58
|
+
gate forward.
|
|
59
|
+
|
|
3
60
|
## [3.1.0] - 2026-09-23
|
|
4
61
|
|
|
5
62
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: themeparks
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Official SDK for the ThemeParks.wiki API
|
|
5
5
|
Project-URL: Homepage, https://api.themeparks.wiki
|
|
6
6
|
Project-URL: Source, https://github.com/ThemeParks/ThemeParks_Python
|
|
@@ -132,7 +132,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
132
132
|
| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. |
|
|
133
133
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
134
134
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
135
|
-
| `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
|
|
135
|
+
| `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. |
|
|
136
136
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
137
137
|
|
|
138
138
|
Example:
|
|
@@ -255,6 +255,50 @@ remaining keys are whatever fields that variant carries.
|
|
|
255
255
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
256
256
|
inputs.
|
|
257
257
|
|
|
258
|
+
## Rate limits
|
|
259
|
+
|
|
260
|
+
`client.rate_limit` is a read-only property, not a constructor option.
|
|
261
|
+
|
|
262
|
+
The API meters requests per minute, and history requests again per hour. Both
|
|
263
|
+
are advertised on every response that can carry them, and the client reads
|
|
264
|
+
them:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
268
|
+
tp.entity(park_id).live()
|
|
269
|
+
|
|
270
|
+
print(tp.rate_limit.rest.remaining) # 299
|
|
271
|
+
print(tp.rate_limit.rest.seconds_until_reset())
|
|
272
|
+
print(tp.rate_limit.history.remaining) # on a history call
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**`None` means the server did not say, never "nothing left".** Use
|
|
276
|
+
`.exhausted`, which is true only when the server actually said zero.
|
|
277
|
+
|
|
278
|
+
Which figures you get depends on the response:
|
|
279
|
+
|
|
280
|
+
- The **per-minute** figures ride most responses, anonymous ones included.
|
|
281
|
+
- The **hourly history** figures are withheld from anything a shared cache may
|
|
282
|
+
store, because they are per-caller and a cache would hand one caller's budget
|
|
283
|
+
to another. In practice you get them on calls made with a key.
|
|
284
|
+
- An **unmetered plan** advertises nothing at all.
|
|
285
|
+
|
|
286
|
+
A response served from a cache is ignored entirely. Its figures belong to
|
|
287
|
+
whoever populated the entry and its countdown is already wrong: a cached
|
|
288
|
+
`remaining: 0` would otherwise make the client sleep out someone else's
|
|
289
|
+
window.
|
|
290
|
+
|
|
291
|
+
The client also acts on what it reads. When a response says the window is
|
|
292
|
+
spent, the next request waits for the advertised reset rather than sending a
|
|
293
|
+
request that is certain to be refused, and to cost a unit of budget being
|
|
294
|
+
refused. Turn that off with `RetryConfig(respect_remaining=False)`.
|
|
295
|
+
|
|
296
|
+
**A 429 is held once for the whole client.** The wait belongs to the caller,
|
|
297
|
+
not to whichever request happened to meet it, so it goes on a shared gate with
|
|
298
|
+
a little jitter. Without that, ten concurrent requests each sleep their own
|
|
299
|
+
copy of `Retry-After` and then all retry at the same instant, re-tripping the
|
|
300
|
+
limit together.
|
|
301
|
+
|
|
258
302
|
## History
|
|
259
303
|
|
|
260
304
|
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
@@ -93,7 +93,7 @@ Both `ThemeParks` and `AsyncThemeParks` take the same keyword-only options:
|
|
|
93
93
|
| `api_key` | `str \| None` | `None` | Sent as the `x-api-key` header. Needed for anything beyond the free tier: deeper history, higher rate limits. |
|
|
94
94
|
| `user_agent` | `str \| None` | `themeparks-sdk-py/<version>` | Sent as the `User-Agent` header. Set this to identify your app. |
|
|
95
95
|
| `timeout` | `float` (seconds) | `10.0` | Per-request timeout. |
|
|
96
|
-
| `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
|
|
96
|
+
| `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. |
|
|
97
97
|
| `cache` | `Cache \| CacheConfig \| bool \| None` | `True` (in-memory LRU) | See **Caching** below. `False` disables caching entirely. |
|
|
98
98
|
|
|
99
99
|
Example:
|
|
@@ -216,6 +216,50 @@ remaining keys are whatever fields that variant carries.
|
|
|
216
216
|
timezone-aware `datetime`, honoring the entity's IANA timezone for naive
|
|
217
217
|
inputs.
|
|
218
218
|
|
|
219
|
+
## Rate limits
|
|
220
|
+
|
|
221
|
+
`client.rate_limit` is a read-only property, not a constructor option.
|
|
222
|
+
|
|
223
|
+
The API meters requests per minute, and history requests again per hour. Both
|
|
224
|
+
are advertised on every response that can carry them, and the client reads
|
|
225
|
+
them:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
229
|
+
tp.entity(park_id).live()
|
|
230
|
+
|
|
231
|
+
print(tp.rate_limit.rest.remaining) # 299
|
|
232
|
+
print(tp.rate_limit.rest.seconds_until_reset())
|
|
233
|
+
print(tp.rate_limit.history.remaining) # on a history call
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**`None` means the server did not say, never "nothing left".** Use
|
|
237
|
+
`.exhausted`, which is true only when the server actually said zero.
|
|
238
|
+
|
|
239
|
+
Which figures you get depends on the response:
|
|
240
|
+
|
|
241
|
+
- The **per-minute** figures ride most responses, anonymous ones included.
|
|
242
|
+
- The **hourly history** figures are withheld from anything a shared cache may
|
|
243
|
+
store, because they are per-caller and a cache would hand one caller's budget
|
|
244
|
+
to another. In practice you get them on calls made with a key.
|
|
245
|
+
- An **unmetered plan** advertises nothing at all.
|
|
246
|
+
|
|
247
|
+
A response served from a cache is ignored entirely. Its figures belong to
|
|
248
|
+
whoever populated the entry and its countdown is already wrong: a cached
|
|
249
|
+
`remaining: 0` would otherwise make the client sleep out someone else's
|
|
250
|
+
window.
|
|
251
|
+
|
|
252
|
+
The client also acts on what it reads. When a response says the window is
|
|
253
|
+
spent, the next request waits for the advertised reset rather than sending a
|
|
254
|
+
request that is certain to be refused, and to cost a unit of budget being
|
|
255
|
+
refused. Turn that off with `RetryConfig(respect_remaining=False)`.
|
|
256
|
+
|
|
257
|
+
**A 429 is held once for the whole client.** The wait belongs to the caller,
|
|
258
|
+
not to whichever request happened to meet it, so it goes on a shared gate with
|
|
259
|
+
a little jitter. Without that, ten concurrent requests each sleep their own
|
|
260
|
+
copy of `Retry-After` and then all retry at the same instant, re-tripping the
|
|
261
|
+
limit together.
|
|
262
|
+
|
|
219
263
|
## History
|
|
220
264
|
|
|
221
265
|
`tp.entity(id).history` reads the archive. Both methods page for you and yield
|
|
@@ -10,6 +10,7 @@ from themeparks._errors import (
|
|
|
10
10
|
ThemeParksError,
|
|
11
11
|
TimeoutError,
|
|
12
12
|
)
|
|
13
|
+
from themeparks._ratelimit import RateLimit, RateLimits
|
|
13
14
|
from themeparks._transport import RetryConfig
|
|
14
15
|
|
|
15
16
|
__all__ = [
|
|
@@ -17,6 +18,8 @@ __all__ = [
|
|
|
17
18
|
"AsyncThemeParks",
|
|
18
19
|
"BudgetExhaustedError",
|
|
19
20
|
"HistorySpan",
|
|
21
|
+
"RateLimit",
|
|
22
|
+
"RateLimits",
|
|
20
23
|
"Cache",
|
|
21
24
|
"CacheConfig",
|
|
22
25
|
"InMemoryLRUCache",
|
|
@@ -10,6 +10,7 @@ import httpx
|
|
|
10
10
|
from themeparks._cache import Cache, CacheConfig, InMemoryLRUCache, ttl_for_path
|
|
11
11
|
from themeparks._ergonomic.destinations import AsyncDestinationsApi, DestinationsApi
|
|
12
12
|
from themeparks._ergonomic.entity import AsyncEntityHandle, EntityHandle
|
|
13
|
+
from themeparks._ratelimit import RateLimits
|
|
13
14
|
from themeparks._raw import AsyncRawClient, RawClient
|
|
14
15
|
from themeparks._transport import AsyncTransport, RetryConfig, SyncTransport
|
|
15
16
|
|
|
@@ -52,6 +53,16 @@ class _CachingSyncTransport:
|
|
|
52
53
|
self._inner = inner
|
|
53
54
|
self._cache = cache
|
|
54
55
|
|
|
56
|
+
@property
|
|
57
|
+
def rate_limit(self) -> RateLimits:
|
|
58
|
+
"""Whatever the inner transport last learned.
|
|
59
|
+
|
|
60
|
+
A cache HIT sends no request and so learns nothing, which is correct:
|
|
61
|
+
the figures then keep saying what the last real response said. They
|
|
62
|
+
are not invalidated by a hit, because a hit spent no budget either.
|
|
63
|
+
"""
|
|
64
|
+
return self._inner.rate_limit
|
|
65
|
+
|
|
55
66
|
def get(self, path: str) -> Any:
|
|
56
67
|
ttl = ttl_for_path(path)
|
|
57
68
|
if ttl > 0:
|
|
@@ -69,6 +80,16 @@ class _CachingAsyncTransport:
|
|
|
69
80
|
self._inner = inner
|
|
70
81
|
self._cache = cache
|
|
71
82
|
|
|
83
|
+
@property
|
|
84
|
+
def rate_limit(self) -> RateLimits:
|
|
85
|
+
"""Whatever the inner transport last learned.
|
|
86
|
+
|
|
87
|
+
A cache HIT sends no request and so learns nothing, which is correct:
|
|
88
|
+
the figures then keep saying what the last real response said. They
|
|
89
|
+
are not invalidated by a hit, because a hit spent no budget either.
|
|
90
|
+
"""
|
|
91
|
+
return self._inner.rate_limit
|
|
92
|
+
|
|
72
93
|
async def get(self, path: str) -> Any:
|
|
73
94
|
ttl = ttl_for_path(path)
|
|
74
95
|
if ttl > 0:
|
|
@@ -125,6 +146,24 @@ class ThemeParks:
|
|
|
125
146
|
)
|
|
126
147
|
self.destinations = DestinationsApi(raw=self.raw)
|
|
127
148
|
|
|
149
|
+
@property
|
|
150
|
+
def rate_limit(self) -> RateLimits:
|
|
151
|
+
"""What the server last said about your two budgets.
|
|
152
|
+
|
|
153
|
+
`rate_limit.rest` is the per-minute REST meter; `rate_limit.history`
|
|
154
|
+
is the separate hourly history budget. Every field can be None,
|
|
155
|
+
because every field can be legitimately absent: an unmetered plan
|
|
156
|
+
advertises nothing, and neither does a publicly cacheable response,
|
|
157
|
+
since the figures belong to whoever populated the cache.
|
|
158
|
+
|
|
159
|
+
None therefore means "the server did not say", never "nothing left".
|
|
160
|
+
|
|
161
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
162
|
+
tp.entity(park).live()
|
|
163
|
+
print(tp.rate_limit.rest.remaining) # e.g. 299
|
|
164
|
+
"""
|
|
165
|
+
return self.raw._t.rate_limit
|
|
166
|
+
|
|
128
167
|
def entity(self, entity_id: str) -> EntityHandle:
|
|
129
168
|
return self._entity_ctor(entity_id)
|
|
130
169
|
|
|
@@ -184,6 +223,24 @@ class AsyncThemeParks:
|
|
|
184
223
|
)
|
|
185
224
|
self.destinations = AsyncDestinationsApi(raw=self.raw)
|
|
186
225
|
|
|
226
|
+
@property
|
|
227
|
+
def rate_limit(self) -> RateLimits:
|
|
228
|
+
"""What the server last said about your two budgets.
|
|
229
|
+
|
|
230
|
+
`rate_limit.rest` is the per-minute REST meter; `rate_limit.history`
|
|
231
|
+
is the separate hourly history budget. Every field can be None,
|
|
232
|
+
because every field can be legitimately absent: an unmetered plan
|
|
233
|
+
advertises nothing, and neither does a publicly cacheable response,
|
|
234
|
+
since the figures belong to whoever populated the cache.
|
|
235
|
+
|
|
236
|
+
None therefore means "the server did not say", never "nothing left".
|
|
237
|
+
|
|
238
|
+
with ThemeParks(api_key=KEY) as tp:
|
|
239
|
+
tp.entity(park).live()
|
|
240
|
+
print(tp.rate_limit.rest.remaining) # e.g. 299
|
|
241
|
+
"""
|
|
242
|
+
return self.raw._t.rate_limit
|
|
243
|
+
|
|
187
244
|
def entity(self, entity_id: str) -> AsyncEntityHandle:
|
|
188
245
|
return self._entity_ctor(entity_id)
|
|
189
246
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""What the server says about your budget, and how to stay inside it.
|
|
2
|
+
|
|
3
|
+
TWO BUDGETS, SEPARATELY METERED. The API meters requests per minute, and
|
|
4
|
+
history requests again per hour. They are different windows over different
|
|
5
|
+
counters, so the server advertises them in two sets of headers:
|
|
6
|
+
|
|
7
|
+
RateLimit-Limit / -Policy / -Remaining / -Reset per minute
|
|
8
|
+
RateLimit-History-Limit / -Policy / -Remaining / -Reset per hour
|
|
9
|
+
|
|
10
|
+
Both were being thrown away. The SDK only ever read `Retry-After`, and only
|
|
11
|
+
after a 429 had already happened -- so it could tell you that you had run out,
|
|
12
|
+
never that you were about to.
|
|
13
|
+
|
|
14
|
+
ABSENCE IS NOT ZERO. A response a shared cache may store carries no per-caller
|
|
15
|
+
figures at all, because they belong to whoever populated the cache entry. That
|
|
16
|
+
is every anonymous response. So `None` here means "the server did not say",
|
|
17
|
+
which is a different thing from "nothing left", and nothing in this module may
|
|
18
|
+
confuse the two.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import random
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
|
|
29
|
+
#: Header prefixes for the two meters.
|
|
30
|
+
_REST_PREFIX = "ratelimit"
|
|
31
|
+
_HISTORY_PREFIX = "ratelimit-history"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _int_or_none(raw: str | None) -> int | None:
|
|
35
|
+
"""These fields are integers per the draft spec; anything else is unknown.
|
|
36
|
+
|
|
37
|
+
`int()` alone was too generous and differed from the JavaScript sibling on
|
|
38
|
+
the same input: PEP 515 means it reads "1_0" as 10. The server only ever
|
|
39
|
+
sends a non-negative integer, so it does not fire today, but a pair of
|
|
40
|
+
libraries whose selling point is parity should not disagree on it.
|
|
41
|
+
"""
|
|
42
|
+
if raw is None:
|
|
43
|
+
return None
|
|
44
|
+
trimmed = raw.strip()
|
|
45
|
+
if not trimmed.isdigit():
|
|
46
|
+
return None
|
|
47
|
+
return int(trimmed)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RateLimit:
|
|
52
|
+
"""One meter's state, as of the last response that mentioned it.
|
|
53
|
+
|
|
54
|
+
Every field is optional because every field can be legitimately absent:
|
|
55
|
+
an unmetered plan advertises nothing, and neither does a publicly
|
|
56
|
+
cacheable response.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
#: Requests allowed per window, or None if the server did not say.
|
|
60
|
+
limit: int | None = None
|
|
61
|
+
#: Requests left in the current window, or None if the server did not say.
|
|
62
|
+
remaining: int | None = None
|
|
63
|
+
#: Seconds until the window resets, as of `observed_at`.
|
|
64
|
+
reset: int | None = None
|
|
65
|
+
#: The raw policy string, e.g. "300;w=60".
|
|
66
|
+
policy: str | None = None
|
|
67
|
+
#: `time.monotonic()` when this was read, so `reset` can be aged.
|
|
68
|
+
observed_at: float | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def exhausted(self) -> bool:
|
|
72
|
+
"""True only when the server SAID there is nothing left.
|
|
73
|
+
|
|
74
|
+
An unknown remaining is not exhaustion. Treating it as such would make
|
|
75
|
+
an anonymous caller, whose responses never carry figures, wait forever.
|
|
76
|
+
"""
|
|
77
|
+
return self.remaining == 0
|
|
78
|
+
|
|
79
|
+
def seconds_until_reset(self, now: float | None = None) -> float | None:
|
|
80
|
+
"""How long is left of the window, counting down from when we read it.
|
|
81
|
+
|
|
82
|
+
`reset` is a relative value frozen at `observed_at`; using it later
|
|
83
|
+
without ageing it is how a client waits far longer than it needs to.
|
|
84
|
+
"""
|
|
85
|
+
if self.reset is None or self.observed_at is None:
|
|
86
|
+
return None
|
|
87
|
+
elapsed = (time.monotonic() if now is None else now) - self.observed_at
|
|
88
|
+
return max(0.0, self.reset - elapsed)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class RateLimits:
|
|
93
|
+
"""Both meters. Reached as `client.rate_limit`."""
|
|
94
|
+
|
|
95
|
+
rest: RateLimit = field(default_factory=RateLimit)
|
|
96
|
+
history: RateLimit = field(default_factory=RateLimit)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_one(headers: Mapping[str, str], prefix: str, now: float) -> RateLimit:
|
|
100
|
+
limit = _int_or_none(headers.get(f"{prefix}-limit"))
|
|
101
|
+
remaining = _int_or_none(headers.get(f"{prefix}-remaining"))
|
|
102
|
+
reset = _int_or_none(headers.get(f"{prefix}-reset"))
|
|
103
|
+
policy = headers.get(f"{prefix}-policy")
|
|
104
|
+
if limit is None and remaining is None and reset is None and policy is None:
|
|
105
|
+
return RateLimit()
|
|
106
|
+
return RateLimit(limit=limit, remaining=remaining, reset=reset, policy=policy, observed_at=now)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_rate_limits(headers: Mapping[str, str], previous: RateLimits) -> RateLimits:
|
|
110
|
+
"""Merge whatever this response said into what we already knew.
|
|
111
|
+
|
|
112
|
+
A response that mentions neither meter leaves both alone, and so does a
|
|
113
|
+
response served from a cache. That matters
|
|
114
|
+
because most responses mention only one: the history headers appear on
|
|
115
|
+
history routes, and on a cacheable response neither appears. Overwriting
|
|
116
|
+
with blanks would mean the last cacheable response erased everything the
|
|
117
|
+
SDK had learned.
|
|
118
|
+
|
|
119
|
+
Header lookup is case-insensitive via httpx's own mapping, but a plain dict
|
|
120
|
+
is accepted too, so the keys are compared lowercased.
|
|
121
|
+
"""
|
|
122
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
123
|
+
# A cache HIT carries the figures of whoever populated the entry, frozen
|
|
124
|
+
# at that moment. They are not ours and the countdown is already wrong, so
|
|
125
|
+
# the honest reading is that this response said nothing.
|
|
126
|
+
age = _int_or_none(lowered.get("age"))
|
|
127
|
+
if age is not None and age > 0:
|
|
128
|
+
return previous
|
|
129
|
+
# No prefix filtering needed: _read_one looks up EXACT keys, so
|
|
130
|
+
# "ratelimit-limit" and "ratelimit-history-limit" cannot collide. An
|
|
131
|
+
# earlier version filtered the history keys out before reading the REST
|
|
132
|
+
# meter; removing that filter changed no behaviour and no test, which is
|
|
133
|
+
# what a redundant guard looks like. The bleed it guarded against is
|
|
134
|
+
# covered by a test either way.
|
|
135
|
+
now = time.monotonic()
|
|
136
|
+
history = _read_one(lowered, _HISTORY_PREFIX, now)
|
|
137
|
+
rest = _read_one(lowered, _REST_PREFIX, now)
|
|
138
|
+
return RateLimits(
|
|
139
|
+
rest=rest if rest.observed_at is not None else previous.rest,
|
|
140
|
+
history=history if history.observed_at is not None else previous.history,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Gate:
|
|
145
|
+
"""One shared "not before" instant for a whole client.
|
|
146
|
+
|
|
147
|
+
WHY SHARED. A 429 applies to the CALLER, not to the request that happened
|
|
148
|
+
to meet it. With a per-request backoff, ten concurrent requests each sleep
|
|
149
|
+
their own Retry-After and then all retry at the same instant, re-tripping
|
|
150
|
+
the limit together -- a thundering herd the client inflicts on itself, and
|
|
151
|
+
on us. One gate means the wait is taken once.
|
|
152
|
+
|
|
153
|
+
Each waiter adds its own small jitter on the way out, because waking
|
|
154
|
+
together is the other half of the same problem.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, jitter: float = 0.25) -> None:
|
|
158
|
+
self._until = 0.0
|
|
159
|
+
self._jitter = jitter
|
|
160
|
+
self._lock = threading.Lock()
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def deadline(self) -> float:
|
|
164
|
+
"""When the gate opens, on the monotonic clock. 0 if it is open."""
|
|
165
|
+
with self._lock:
|
|
166
|
+
return self._until
|
|
167
|
+
|
|
168
|
+
def close_for(self, seconds: float) -> None:
|
|
169
|
+
"""Hold every request on this client for at least `seconds`."""
|
|
170
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
171
|
+
with self._lock:
|
|
172
|
+
# Never bring the gate forward: a shorter Retry-After arriving
|
|
173
|
+
# while a longer one is in force would release the herd early.
|
|
174
|
+
self._until = max(self._until, deadline)
|
|
175
|
+
|
|
176
|
+
def wait_seconds(self) -> float:
|
|
177
|
+
"""How long this caller should hold off, jitter included. 0 if open."""
|
|
178
|
+
with self._lock:
|
|
179
|
+
remaining = self._until - time.monotonic()
|
|
180
|
+
if remaining <= 0:
|
|
181
|
+
return 0.0
|
|
182
|
+
return remaining + random.random() * self._jitter
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Sync and async HTTP transport for the ThemeParks SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import email.utils
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from themeparks._errors import APIError, NetworkError, RateLimitError, TimeoutError
|
|
17
|
+
from themeparks._ratelimit import Gate, RateLimits, read_rate_limits
|
|
18
|
+
|
|
19
|
+
_STATUS_TOO_MANY_REQUESTS = 429
|
|
20
|
+
_STATUS_SERVER_ERROR = 500
|
|
21
|
+
_ERROR_BODY_EXCERPT_LIMIT = 200
|
|
22
|
+
_ERROR_MESSAGE_LIMIT = 300
|
|
23
|
+
#: Below this, a computed wait is floating-point residue rather than a wait.
|
|
24
|
+
_MIN_SLEEP_SECONDS = 0.001
|
|
25
|
+
#: Spread applied to a synchronised release, so waiters do not wake as one.
|
|
26
|
+
_SPREAD_SECONDS = 0.25
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RetryConfig:
|
|
31
|
+
max_retries: int = 3
|
|
32
|
+
respect_429: bool = True
|
|
33
|
+
#: Longest `Retry-After` this client will sleep through, in seconds.
|
|
34
|
+
#:
|
|
35
|
+
#: A REST 429 asks for seconds and is worth waiting out. A HISTORY 429 is
|
|
36
|
+
#: a different animal: that budget is hourly, so a spent one can ask for
|
|
37
|
+
#: most of an hour, and honouring it up to `max_retries` times means a
|
|
38
|
+
#: process that sits silent for hours and looks hung. Past this cap we do
|
|
39
|
+
#: not sleep at all, and raise `RateLimitError` carrying `retry_after` so
|
|
40
|
+
#: the caller can checkpoint and come back.
|
|
41
|
+
max_retry_after: float = 120.0
|
|
42
|
+
#: Wait out a window the server has already told us is spent.
|
|
43
|
+
#:
|
|
44
|
+
#: When a response says `remaining: 0`, the next request is a guaranteed
|
|
45
|
+
#: 429 that also costs us a unit of the caller's budget to refuse. Waiting
|
|
46
|
+
#: for the reset it advertised is strictly better than sending it. Off
|
|
47
|
+
#: turns the client back into a purely reactive one.
|
|
48
|
+
respect_remaining: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_retry_after(raw: str | None) -> float | None:
|
|
52
|
+
"""Seconds to wait, or None when the header gives us nothing usable.
|
|
53
|
+
|
|
54
|
+
NONE AND ZERO ARE DIFFERENT ANSWERS, and conflating them turned the client
|
|
55
|
+
into a hammer. Only `None` reaches the exponential backoff, so a header
|
|
56
|
+
that parsed to 0 -- which `Retry-After: 0` is, legally, per RFC 9110, and
|
|
57
|
+
which a negative or already-past date also produces -- meant no wait at
|
|
58
|
+
all. Measured: four requests in 3ms against a server that had just said
|
|
59
|
+
429, where an absent header correctly took 1962ms. Ten threads made that
|
|
60
|
+
204 requests a second at a server actively refusing them.
|
|
61
|
+
|
|
62
|
+
So a non-positive wait is not a wait, and we say None.
|
|
63
|
+
"""
|
|
64
|
+
if raw is None:
|
|
65
|
+
return None
|
|
66
|
+
seconds: float | None
|
|
67
|
+
try:
|
|
68
|
+
seconds = float(raw)
|
|
69
|
+
except ValueError:
|
|
70
|
+
seconds = _parse_http_date_delta(raw)
|
|
71
|
+
if seconds is None or seconds <= 0:
|
|
72
|
+
return None
|
|
73
|
+
return seconds
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_http_date_delta(raw: str) -> float | None:
|
|
77
|
+
"""An HTTP-date Retry-After, as seconds from now.
|
|
78
|
+
|
|
79
|
+
RFC 9110 requires the IMF-fixdate (GMT) form, but RFC 5322 `-0000` and a
|
|
80
|
+
bare date both appear in the wild, and `parsedate_to_datetime` returns a
|
|
81
|
+
NAIVE datetime for them. `.timestamp()` then reads it as local time, so on
|
|
82
|
+
a host an hour off UTC the answer was wrong by exactly that hour -- and
|
|
83
|
+
because it came out negative it became 0, landing in the no-backoff spin
|
|
84
|
+
above. Assume UTC when the sender did not say.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
parsed = email.utils.parsedate_to_datetime(raw)
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
if parsed.tzinfo is None:
|
|
91
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
92
|
+
return parsed.timestamp() - time.time()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _wait_too_long(retry_after: float | None, retry: RetryConfig) -> bool:
|
|
96
|
+
"""True when the server's wait is longer than this client will sleep for."""
|
|
97
|
+
return retry_after is not None and retry_after > retry.max_retry_after
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _backoff(attempt: int) -> float:
|
|
101
|
+
base = 0.25 * (2**attempt)
|
|
102
|
+
jittered: float = base + random.random() * base * 0.25
|
|
103
|
+
return min(jittered, 5.0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_error_message(status: int, reason: str, body: Any) -> str:
|
|
107
|
+
"""Build a human-useful error message from an HTTP response.
|
|
108
|
+
|
|
109
|
+
Includes a body excerpt when present so callers see *why* the request
|
|
110
|
+
failed without having to inspect ``exc.body`` manually. Dict bodies
|
|
111
|
+
with an ``"error"`` key are formatted specially; other bodies are
|
|
112
|
+
stringified and truncated to 200 characters.
|
|
113
|
+
"""
|
|
114
|
+
if body is None or body == "":
|
|
115
|
+
return f"{status} {reason}"
|
|
116
|
+
if isinstance(body, dict) and "error" in body:
|
|
117
|
+
return f"{status} {reason}: {body['error']}"[:_ERROR_MESSAGE_LIMIT]
|
|
118
|
+
body_str = str(body)
|
|
119
|
+
if len(body_str) > _ERROR_BODY_EXCERPT_LIMIT:
|
|
120
|
+
body_str = body_str[:_ERROR_BODY_EXCERPT_LIMIT] + "..."
|
|
121
|
+
return f"{status} {reason}: {body_str}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
125
|
+
ct = response.headers.get("content-type", "")
|
|
126
|
+
if "application/json" in ct:
|
|
127
|
+
try:
|
|
128
|
+
return response.json()
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
return response.text
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _headers(user_agent: str, api_key: str | None) -> dict[str, str]:
|
|
138
|
+
"""Request headers, with the API key when one was supplied.
|
|
139
|
+
|
|
140
|
+
The SDK could not send a key at all until 2026-09-23, which meant the
|
|
141
|
+
official library could reach only the anonymous window: seven days of
|
|
142
|
+
history and the unauthenticated rate limit. A paying customer had to drop
|
|
143
|
+
to raw HTTP to use what they had bought.
|
|
144
|
+
|
|
145
|
+
`x-api-key` is the header the API documents. Nothing here logs or repeats
|
|
146
|
+
the value.
|
|
147
|
+
"""
|
|
148
|
+
headers = {"user-agent": user_agent, "accept": "application/json"}
|
|
149
|
+
if api_key:
|
|
150
|
+
headers["x-api-key"] = api_key
|
|
151
|
+
return headers
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class SyncTransport:
|
|
155
|
+
def __init__( # noqa: PLR0913
|
|
156
|
+
self,
|
|
157
|
+
*,
|
|
158
|
+
client: httpx.Client,
|
|
159
|
+
base_url: str,
|
|
160
|
+
user_agent: str,
|
|
161
|
+
retry: RetryConfig,
|
|
162
|
+
api_key: str | None = None,
|
|
163
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
164
|
+
) -> None:
|
|
165
|
+
self._client = client
|
|
166
|
+
self._base_url = base_url.rstrip("/")
|
|
167
|
+
self._user_agent = user_agent
|
|
168
|
+
self._retry = retry
|
|
169
|
+
self._headers = _headers(user_agent, api_key)
|
|
170
|
+
self._sleep = sleep
|
|
171
|
+
self.rate_limit = RateLimits()
|
|
172
|
+
self._gate = Gate()
|
|
173
|
+
|
|
174
|
+
def _hold(self, budget: float) -> float:
|
|
175
|
+
"""Wait before sending, if we already know this request would fail.
|
|
176
|
+
|
|
177
|
+
Returns how long it slept, so the caller can keep a running total. The
|
|
178
|
+
TOTAL is what `max_retry_after` bounds, not each leg: the gate wait and
|
|
179
|
+
the spent-window wait are both self-initiated holds, and they stack.
|
|
180
|
+
Measured before this budget existed: a 429 carrying `Retry-After: 5`
|
|
181
|
+
and `RateLimit-Reset: 60` slept 5 then 55, three times over -- 180
|
|
182
|
+
seconds inside one call whose cap was 120. Each leg was under the cap,
|
|
183
|
+
so the per-leg check never fired, and the promise the cap makes was
|
|
184
|
+
reachable around.
|
|
185
|
+
|
|
186
|
+
Two reasons to hold, and they are different. The GATE is a 429 the
|
|
187
|
+
server has already issued to this caller: the wait belongs to them,
|
|
188
|
+
not to whichever request met it, so it is shared and taken once. The
|
|
189
|
+
REMAINING check is a window the server told us is spent -- sending
|
|
190
|
+
into it is a guaranteed 429 that also costs a unit of budget to
|
|
191
|
+
refuse, so waiting for the advertised reset is strictly better.
|
|
192
|
+
|
|
193
|
+
A remaining we were never told is not a spent one. Anonymous
|
|
194
|
+
responses carry no figures at all, so an unknown must never hold.
|
|
195
|
+
"""
|
|
196
|
+
spent = 0.0
|
|
197
|
+
# Re-read the gate after waiting. It slept once and returned, so a
|
|
198
|
+
# waiter that woke while someone else's 429 had pushed the gate
|
|
199
|
+
# further out sent anyway. Only loop when the deadline actually
|
|
200
|
+
# MOVED: re-reading unconditionally spins against any clock that does
|
|
201
|
+
# not advance.
|
|
202
|
+
while True:
|
|
203
|
+
before = self._gate.deadline
|
|
204
|
+
wait = min(self._gate.wait_seconds(), budget - spent)
|
|
205
|
+
if wait <= _MIN_SLEEP_SECONDS:
|
|
206
|
+
break
|
|
207
|
+
self._sleep(wait)
|
|
208
|
+
spent += wait
|
|
209
|
+
if self._gate.deadline <= before:
|
|
210
|
+
break
|
|
211
|
+
if not self._retry.respect_remaining:
|
|
212
|
+
return spent
|
|
213
|
+
for meter in (self.rate_limit.rest, self.rate_limit.history):
|
|
214
|
+
if not meter.exhausted:
|
|
215
|
+
continue
|
|
216
|
+
left = meter.seconds_until_reset()
|
|
217
|
+
if left is None or left <= 0 or left > self._retry.max_retry_after:
|
|
218
|
+
# Past the cap we do not sit on it: the caller gets the 429
|
|
219
|
+
# and its Retry-After, and can decide. Same rule the retry
|
|
220
|
+
# path follows.
|
|
221
|
+
continue
|
|
222
|
+
# Jittered like the gate. Without it every waiter derived `left`
|
|
223
|
+
# from the same observed_at and woke at the same absolute
|
|
224
|
+
# instant -- the tightest burst in the client, on the very branch
|
|
225
|
+
# that exists to avoid a 429.
|
|
226
|
+
left = min(left + random.random() * _SPREAD_SECONDS, budget - spent)
|
|
227
|
+
if left <= _MIN_SLEEP_SECONDS:
|
|
228
|
+
break
|
|
229
|
+
self._sleep(left)
|
|
230
|
+
spent += left
|
|
231
|
+
return spent
|
|
232
|
+
|
|
233
|
+
def get(self, path: str) -> Any:
|
|
234
|
+
url = self._base_url + path
|
|
235
|
+
attempt = 0
|
|
236
|
+
# One budget for the whole call, because that is what the cap promises.
|
|
237
|
+
budget = self._retry.max_retry_after
|
|
238
|
+
while True:
|
|
239
|
+
budget -= self._hold(budget)
|
|
240
|
+
try:
|
|
241
|
+
response = self._client.get(
|
|
242
|
+
path,
|
|
243
|
+
headers=self._headers,
|
|
244
|
+
)
|
|
245
|
+
except httpx.TimeoutException as exc:
|
|
246
|
+
raise TimeoutError(f"request to {url} timed out") from exc
|
|
247
|
+
except httpx.HTTPError as exc:
|
|
248
|
+
if attempt < self._retry.max_retries:
|
|
249
|
+
self._sleep(_backoff(attempt))
|
|
250
|
+
attempt += 1
|
|
251
|
+
continue
|
|
252
|
+
raise NetworkError(f"network error calling {url}") from exc
|
|
253
|
+
|
|
254
|
+
self.rate_limit = read_rate_limits(response.headers, self.rate_limit)
|
|
255
|
+
|
|
256
|
+
if response.is_success:
|
|
257
|
+
return _parse_body(response)
|
|
258
|
+
|
|
259
|
+
body = _parse_body(response)
|
|
260
|
+
status = response.status_code
|
|
261
|
+
|
|
262
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
263
|
+
if (
|
|
264
|
+
status == _STATUS_TOO_MANY_REQUESTS
|
|
265
|
+
and self._retry.respect_429
|
|
266
|
+
and retry_after is not None
|
|
267
|
+
and not _wait_too_long(retry_after, self._retry)
|
|
268
|
+
):
|
|
269
|
+
# The wait belongs to the CALLER, not to whichever request met
|
|
270
|
+
# it, so it goes on the shared gate and _hold() serves it once.
|
|
271
|
+
#
|
|
272
|
+
# respect_429=False means "do not wait on a 429", so it must
|
|
273
|
+
# gate the gate too. Without this the caller got the exception
|
|
274
|
+
# they asked for and then their NEXT call silently blocked,
|
|
275
|
+
# which is an opt-out that does not opt out.
|
|
276
|
+
#
|
|
277
|
+
# Past the cap the gate is left OPEN on purpose: we raise
|
|
278
|
+
# instead, and blocking the caller's next call for most of an
|
|
279
|
+
# hour is the opposite of letting them checkpoint and resume.
|
|
280
|
+
self._gate.close_for(retry_after)
|
|
281
|
+
if (
|
|
282
|
+
status == _STATUS_TOO_MANY_REQUESTS
|
|
283
|
+
and self._retry.respect_429
|
|
284
|
+
and attempt < self._retry.max_retries
|
|
285
|
+
and not _wait_too_long(retry_after, self._retry)
|
|
286
|
+
):
|
|
287
|
+
# No sleep here: the gate above holds the wait and _hold()
|
|
288
|
+
# at the top of the loop serves it once. Paying it here too
|
|
289
|
+
# would double every backoff, and ten concurrent requests
|
|
290
|
+
# would each pay their own and then retry in unison.
|
|
291
|
+
if retry_after is None:
|
|
292
|
+
self._sleep(_backoff(attempt))
|
|
293
|
+
attempt += 1
|
|
294
|
+
continue
|
|
295
|
+
if status == _STATUS_TOO_MANY_REQUESTS:
|
|
296
|
+
raise RateLimitError(
|
|
297
|
+
_format_error_message(status, response.reason_phrase, body),
|
|
298
|
+
status=status,
|
|
299
|
+
body=body,
|
|
300
|
+
url=url,
|
|
301
|
+
retry_after=retry_after,
|
|
302
|
+
)
|
|
303
|
+
if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries:
|
|
304
|
+
self._sleep(_backoff(attempt))
|
|
305
|
+
attempt += 1
|
|
306
|
+
continue
|
|
307
|
+
raise APIError(
|
|
308
|
+
_format_error_message(status, response.reason_phrase, body),
|
|
309
|
+
status=status,
|
|
310
|
+
body=body,
|
|
311
|
+
url=url,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class AsyncTransport:
|
|
316
|
+
def __init__( # noqa: PLR0913
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
client: httpx.AsyncClient,
|
|
320
|
+
base_url: str,
|
|
321
|
+
user_agent: str,
|
|
322
|
+
retry: RetryConfig,
|
|
323
|
+
api_key: str | None = None,
|
|
324
|
+
sleep: Callable[..., Awaitable[None]] | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
self._client = client
|
|
327
|
+
self._base_url = base_url.rstrip("/")
|
|
328
|
+
self._user_agent = user_agent
|
|
329
|
+
self._retry = retry
|
|
330
|
+
self._headers = _headers(user_agent, api_key)
|
|
331
|
+
self._sleep: Callable[..., Awaitable[None]] = sleep if sleep is not None else asyncio.sleep
|
|
332
|
+
self.rate_limit = RateLimits()
|
|
333
|
+
self._gate = Gate()
|
|
334
|
+
|
|
335
|
+
async def _hold(self, budget: float) -> float:
|
|
336
|
+
"""Asynchronous mirror of :meth:`SyncTransport._hold`."""
|
|
337
|
+
spent = 0.0
|
|
338
|
+
# Re-read the gate after waiting. It slept once and returned, so a
|
|
339
|
+
# waiter that woke while someone else's 429 had pushed the gate
|
|
340
|
+
# further out sent anyway. Only loop when the deadline actually
|
|
341
|
+
# MOVED: re-reading unconditionally spins against any clock that does
|
|
342
|
+
# not advance.
|
|
343
|
+
while True:
|
|
344
|
+
before = self._gate.deadline
|
|
345
|
+
wait = min(self._gate.wait_seconds(), budget - spent)
|
|
346
|
+
if wait <= _MIN_SLEEP_SECONDS:
|
|
347
|
+
break
|
|
348
|
+
await self._sleep(wait)
|
|
349
|
+
spent += wait
|
|
350
|
+
if self._gate.deadline <= before:
|
|
351
|
+
break
|
|
352
|
+
if not self._retry.respect_remaining:
|
|
353
|
+
return spent
|
|
354
|
+
for meter in (self.rate_limit.rest, self.rate_limit.history):
|
|
355
|
+
if not meter.exhausted:
|
|
356
|
+
continue
|
|
357
|
+
left = meter.seconds_until_reset()
|
|
358
|
+
if left is None or left <= 0 or left > self._retry.max_retry_after:
|
|
359
|
+
continue
|
|
360
|
+
# Jittered like the gate. Without it every waiter derived `left`
|
|
361
|
+
# from the same observed_at and woke at the same absolute
|
|
362
|
+
# instant -- the tightest burst in the client, on the very branch
|
|
363
|
+
# that exists to avoid a 429.
|
|
364
|
+
left = min(left + random.random() * _SPREAD_SECONDS, budget - spent)
|
|
365
|
+
if left <= _MIN_SLEEP_SECONDS:
|
|
366
|
+
break
|
|
367
|
+
await self._sleep(left)
|
|
368
|
+
spent += left
|
|
369
|
+
return spent
|
|
370
|
+
|
|
371
|
+
async def get(self, path: str) -> Any:
|
|
372
|
+
url = self._base_url + path
|
|
373
|
+
attempt = 0
|
|
374
|
+
budget = self._retry.max_retry_after
|
|
375
|
+
while True:
|
|
376
|
+
budget -= await self._hold(budget)
|
|
377
|
+
try:
|
|
378
|
+
response = await self._client.get(
|
|
379
|
+
path,
|
|
380
|
+
headers=self._headers,
|
|
381
|
+
)
|
|
382
|
+
except httpx.TimeoutException as exc:
|
|
383
|
+
raise TimeoutError(f"request to {url} timed out") from exc
|
|
384
|
+
except httpx.HTTPError as exc:
|
|
385
|
+
if attempt < self._retry.max_retries:
|
|
386
|
+
await self._sleep(_backoff(attempt))
|
|
387
|
+
attempt += 1
|
|
388
|
+
continue
|
|
389
|
+
raise NetworkError(f"network error calling {url}") from exc
|
|
390
|
+
|
|
391
|
+
self.rate_limit = read_rate_limits(response.headers, self.rate_limit)
|
|
392
|
+
|
|
393
|
+
if response.is_success:
|
|
394
|
+
return _parse_body(response)
|
|
395
|
+
|
|
396
|
+
body = _parse_body(response)
|
|
397
|
+
status = response.status_code
|
|
398
|
+
|
|
399
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
400
|
+
if (
|
|
401
|
+
status == _STATUS_TOO_MANY_REQUESTS
|
|
402
|
+
and self._retry.respect_429
|
|
403
|
+
and retry_after is not None
|
|
404
|
+
and not _wait_too_long(retry_after, self._retry)
|
|
405
|
+
):
|
|
406
|
+
# The wait belongs to the CALLER, not to whichever request met
|
|
407
|
+
# it, so it goes on the shared gate and _hold() serves it once.
|
|
408
|
+
#
|
|
409
|
+
# respect_429=False means "do not wait on a 429", so it must
|
|
410
|
+
# gate the gate too. Without this the caller got the exception
|
|
411
|
+
# they asked for and then their NEXT call silently blocked,
|
|
412
|
+
# which is an opt-out that does not opt out.
|
|
413
|
+
#
|
|
414
|
+
# Past the cap the gate is left OPEN on purpose: we raise
|
|
415
|
+
# instead, and blocking the caller's next call for most of an
|
|
416
|
+
# hour is the opposite of letting them checkpoint and resume.
|
|
417
|
+
self._gate.close_for(retry_after)
|
|
418
|
+
if (
|
|
419
|
+
status == _STATUS_TOO_MANY_REQUESTS
|
|
420
|
+
and self._retry.respect_429
|
|
421
|
+
and attempt < self._retry.max_retries
|
|
422
|
+
and not _wait_too_long(retry_after, self._retry)
|
|
423
|
+
):
|
|
424
|
+
# No sleep here: the gate above holds the wait and _hold()
|
|
425
|
+
# at the top of the loop serves it once. Paying it here too
|
|
426
|
+
# would double every backoff, and ten concurrent requests
|
|
427
|
+
# would each pay their own and then retry in unison.
|
|
428
|
+
if retry_after is None:
|
|
429
|
+
await self._sleep(_backoff(attempt))
|
|
430
|
+
attempt += 1
|
|
431
|
+
continue
|
|
432
|
+
if status == _STATUS_TOO_MANY_REQUESTS:
|
|
433
|
+
raise RateLimitError(
|
|
434
|
+
_format_error_message(status, response.reason_phrase, body),
|
|
435
|
+
status=status,
|
|
436
|
+
body=body,
|
|
437
|
+
url=url,
|
|
438
|
+
retry_after=retry_after,
|
|
439
|
+
)
|
|
440
|
+
if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries:
|
|
441
|
+
await self._sleep(_backoff(attempt))
|
|
442
|
+
attempt += 1
|
|
443
|
+
continue
|
|
444
|
+
raise APIError(
|
|
445
|
+
_format_error_message(status, response.reason_phrase, body),
|
|
446
|
+
status=status,
|
|
447
|
+
body=body,
|
|
448
|
+
url=url,
|
|
449
|
+
)
|
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
"""Sync and async HTTP transport for the ThemeParks SDK."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import email.utils
|
|
7
|
-
import random
|
|
8
|
-
import time
|
|
9
|
-
from collections.abc import Awaitable, Callable
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
import httpx
|
|
14
|
-
|
|
15
|
-
from themeparks._errors import APIError, NetworkError, RateLimitError, TimeoutError
|
|
16
|
-
|
|
17
|
-
_STATUS_TOO_MANY_REQUESTS = 429
|
|
18
|
-
_STATUS_SERVER_ERROR = 500
|
|
19
|
-
_ERROR_BODY_EXCERPT_LIMIT = 200
|
|
20
|
-
_ERROR_MESSAGE_LIMIT = 300
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@dataclass
|
|
24
|
-
class RetryConfig:
|
|
25
|
-
max_retries: int = 3
|
|
26
|
-
respect_429: bool = True
|
|
27
|
-
#: Longest `Retry-After` this client will sleep through, in seconds.
|
|
28
|
-
#:
|
|
29
|
-
#: A REST 429 asks for seconds and is worth waiting out. A HISTORY 429 is
|
|
30
|
-
#: a different animal: that budget is hourly, so a spent one can ask for
|
|
31
|
-
#: most of an hour, and honouring it up to `max_retries` times means a
|
|
32
|
-
#: process that sits silent for hours and looks hung. Past this cap we do
|
|
33
|
-
#: not sleep at all, and raise `RateLimitError` carrying `retry_after` so
|
|
34
|
-
#: the caller can checkpoint and come back.
|
|
35
|
-
max_retry_after: float = 120.0
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _parse_retry_after(raw: str | None) -> float | None:
|
|
39
|
-
if raw is None:
|
|
40
|
-
return None
|
|
41
|
-
try:
|
|
42
|
-
return max(0.0, float(raw))
|
|
43
|
-
except ValueError:
|
|
44
|
-
pass
|
|
45
|
-
try:
|
|
46
|
-
parsed = email.utils.parsedate_to_datetime(raw)
|
|
47
|
-
return max(0.0, parsed.timestamp() - time.time())
|
|
48
|
-
except Exception:
|
|
49
|
-
return None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _wait_too_long(retry_after: float | None, retry: RetryConfig) -> bool:
|
|
53
|
-
"""True when the server's wait is longer than this client will sleep for."""
|
|
54
|
-
return retry_after is not None and retry_after > retry.max_retry_after
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _backoff(attempt: int) -> float:
|
|
58
|
-
base = 0.25 * (2**attempt)
|
|
59
|
-
jittered: float = base + random.random() * base * 0.25
|
|
60
|
-
return min(jittered, 5.0)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _format_error_message(status: int, reason: str, body: Any) -> str:
|
|
64
|
-
"""Build a human-useful error message from an HTTP response.
|
|
65
|
-
|
|
66
|
-
Includes a body excerpt when present so callers see *why* the request
|
|
67
|
-
failed without having to inspect ``exc.body`` manually. Dict bodies
|
|
68
|
-
with an ``"error"`` key are formatted specially; other bodies are
|
|
69
|
-
stringified and truncated to 200 characters.
|
|
70
|
-
"""
|
|
71
|
-
if body is None or body == "":
|
|
72
|
-
return f"{status} {reason}"
|
|
73
|
-
if isinstance(body, dict) and "error" in body:
|
|
74
|
-
return f"{status} {reason}: {body['error']}"[:_ERROR_MESSAGE_LIMIT]
|
|
75
|
-
body_str = str(body)
|
|
76
|
-
if len(body_str) > _ERROR_BODY_EXCERPT_LIMIT:
|
|
77
|
-
body_str = body_str[:_ERROR_BODY_EXCERPT_LIMIT] + "..."
|
|
78
|
-
return f"{status} {reason}: {body_str}"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _parse_body(response: httpx.Response) -> Any:
|
|
82
|
-
ct = response.headers.get("content-type", "")
|
|
83
|
-
if "application/json" in ct:
|
|
84
|
-
try:
|
|
85
|
-
return response.json()
|
|
86
|
-
except Exception:
|
|
87
|
-
return None
|
|
88
|
-
try:
|
|
89
|
-
return response.text
|
|
90
|
-
except Exception:
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _headers(user_agent: str, api_key: str | None) -> dict[str, str]:
|
|
95
|
-
"""Request headers, with the API key when one was supplied.
|
|
96
|
-
|
|
97
|
-
The SDK could not send a key at all until 2026-09-23, which meant the
|
|
98
|
-
official library could reach only the anonymous window: seven days of
|
|
99
|
-
history and the unauthenticated rate limit. A paying customer had to drop
|
|
100
|
-
to raw HTTP to use what they had bought.
|
|
101
|
-
|
|
102
|
-
`x-api-key` is the header the API documents. Nothing here logs or repeats
|
|
103
|
-
the value.
|
|
104
|
-
"""
|
|
105
|
-
headers = {"user-agent": user_agent, "accept": "application/json"}
|
|
106
|
-
if api_key:
|
|
107
|
-
headers["x-api-key"] = api_key
|
|
108
|
-
return headers
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
class SyncTransport:
|
|
112
|
-
def __init__( # noqa: PLR0913
|
|
113
|
-
self,
|
|
114
|
-
*,
|
|
115
|
-
client: httpx.Client,
|
|
116
|
-
base_url: str,
|
|
117
|
-
user_agent: str,
|
|
118
|
-
retry: RetryConfig,
|
|
119
|
-
api_key: str | None = None,
|
|
120
|
-
sleep: Callable[[float], None] = time.sleep,
|
|
121
|
-
) -> None:
|
|
122
|
-
self._client = client
|
|
123
|
-
self._base_url = base_url.rstrip("/")
|
|
124
|
-
self._user_agent = user_agent
|
|
125
|
-
self._retry = retry
|
|
126
|
-
self._headers = _headers(user_agent, api_key)
|
|
127
|
-
self._sleep = sleep
|
|
128
|
-
|
|
129
|
-
def get(self, path: str) -> Any:
|
|
130
|
-
url = self._base_url + path
|
|
131
|
-
attempt = 0
|
|
132
|
-
while True:
|
|
133
|
-
try:
|
|
134
|
-
response = self._client.get(
|
|
135
|
-
path,
|
|
136
|
-
headers=self._headers,
|
|
137
|
-
)
|
|
138
|
-
except httpx.TimeoutException as exc:
|
|
139
|
-
raise TimeoutError(f"request to {url} timed out") from exc
|
|
140
|
-
except httpx.HTTPError as exc:
|
|
141
|
-
if attempt < self._retry.max_retries:
|
|
142
|
-
self._sleep(_backoff(attempt))
|
|
143
|
-
attempt += 1
|
|
144
|
-
continue
|
|
145
|
-
raise NetworkError(f"network error calling {url}") from exc
|
|
146
|
-
|
|
147
|
-
if response.is_success:
|
|
148
|
-
return _parse_body(response)
|
|
149
|
-
|
|
150
|
-
body = _parse_body(response)
|
|
151
|
-
status = response.status_code
|
|
152
|
-
|
|
153
|
-
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
154
|
-
if (
|
|
155
|
-
status == _STATUS_TOO_MANY_REQUESTS
|
|
156
|
-
and self._retry.respect_429
|
|
157
|
-
and attempt < self._retry.max_retries
|
|
158
|
-
and not _wait_too_long(retry_after, self._retry)
|
|
159
|
-
):
|
|
160
|
-
self._sleep(retry_after if retry_after is not None else _backoff(attempt))
|
|
161
|
-
attempt += 1
|
|
162
|
-
continue
|
|
163
|
-
if status == _STATUS_TOO_MANY_REQUESTS:
|
|
164
|
-
raise RateLimitError(
|
|
165
|
-
_format_error_message(status, response.reason_phrase, body),
|
|
166
|
-
status=status,
|
|
167
|
-
body=body,
|
|
168
|
-
url=url,
|
|
169
|
-
retry_after=retry_after,
|
|
170
|
-
)
|
|
171
|
-
if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries:
|
|
172
|
-
self._sleep(_backoff(attempt))
|
|
173
|
-
attempt += 1
|
|
174
|
-
continue
|
|
175
|
-
raise APIError(
|
|
176
|
-
_format_error_message(status, response.reason_phrase, body),
|
|
177
|
-
status=status,
|
|
178
|
-
body=body,
|
|
179
|
-
url=url,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
class AsyncTransport:
|
|
184
|
-
def __init__( # noqa: PLR0913
|
|
185
|
-
self,
|
|
186
|
-
*,
|
|
187
|
-
client: httpx.AsyncClient,
|
|
188
|
-
base_url: str,
|
|
189
|
-
user_agent: str,
|
|
190
|
-
retry: RetryConfig,
|
|
191
|
-
api_key: str | None = None,
|
|
192
|
-
sleep: Callable[..., Awaitable[None]] | None = None,
|
|
193
|
-
) -> None:
|
|
194
|
-
self._client = client
|
|
195
|
-
self._base_url = base_url.rstrip("/")
|
|
196
|
-
self._user_agent = user_agent
|
|
197
|
-
self._retry = retry
|
|
198
|
-
self._headers = _headers(user_agent, api_key)
|
|
199
|
-
self._sleep: Callable[..., Awaitable[None]] = sleep if sleep is not None else asyncio.sleep
|
|
200
|
-
|
|
201
|
-
async def get(self, path: str) -> Any:
|
|
202
|
-
url = self._base_url + path
|
|
203
|
-
attempt = 0
|
|
204
|
-
while True:
|
|
205
|
-
try:
|
|
206
|
-
response = await self._client.get(
|
|
207
|
-
path,
|
|
208
|
-
headers=self._headers,
|
|
209
|
-
)
|
|
210
|
-
except httpx.TimeoutException as exc:
|
|
211
|
-
raise TimeoutError(f"request to {url} timed out") from exc
|
|
212
|
-
except httpx.HTTPError as exc:
|
|
213
|
-
if attempt < self._retry.max_retries:
|
|
214
|
-
await self._sleep(_backoff(attempt))
|
|
215
|
-
attempt += 1
|
|
216
|
-
continue
|
|
217
|
-
raise NetworkError(f"network error calling {url}") from exc
|
|
218
|
-
|
|
219
|
-
if response.is_success:
|
|
220
|
-
return _parse_body(response)
|
|
221
|
-
|
|
222
|
-
body = _parse_body(response)
|
|
223
|
-
status = response.status_code
|
|
224
|
-
|
|
225
|
-
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
226
|
-
if (
|
|
227
|
-
status == _STATUS_TOO_MANY_REQUESTS
|
|
228
|
-
and self._retry.respect_429
|
|
229
|
-
and attempt < self._retry.max_retries
|
|
230
|
-
and not _wait_too_long(retry_after, self._retry)
|
|
231
|
-
):
|
|
232
|
-
await self._sleep(retry_after if retry_after is not None else _backoff(attempt))
|
|
233
|
-
attempt += 1
|
|
234
|
-
continue
|
|
235
|
-
if status == _STATUS_TOO_MANY_REQUESTS:
|
|
236
|
-
raise RateLimitError(
|
|
237
|
-
_format_error_message(status, response.reason_phrase, body),
|
|
238
|
-
status=status,
|
|
239
|
-
body=body,
|
|
240
|
-
url=url,
|
|
241
|
-
retry_after=retry_after,
|
|
242
|
-
)
|
|
243
|
-
if status >= _STATUS_SERVER_ERROR and attempt < self._retry.max_retries:
|
|
244
|
-
await self._sleep(_backoff(attempt))
|
|
245
|
-
attempt += 1
|
|
246
|
-
continue
|
|
247
|
-
raise APIError(
|
|
248
|
-
_format_error_message(status, response.reason_phrase, body),
|
|
249
|
-
status=status,
|
|
250
|
-
body=body,
|
|
251
|
-
url=url,
|
|
252
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|