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.
Files changed (24) hide show
  1. {themeparks-3.1.0 → themeparks-3.2.0}/CHANGELOG.md +57 -0
  2. {themeparks-3.1.0 → themeparks-3.2.0}/PKG-INFO +46 -2
  3. {themeparks-3.1.0 → themeparks-3.2.0}/README.md +45 -1
  4. {themeparks-3.1.0 → themeparks-3.2.0}/pyproject.toml +1 -1
  5. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/__init__.py +3 -0
  6. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_client.py +57 -0
  7. themeparks-3.2.0/themeparks/_ratelimit.py +182 -0
  8. themeparks-3.2.0/themeparks/_transport.py +449 -0
  9. themeparks-3.1.0/themeparks/_transport.py +0 -252
  10. {themeparks-3.1.0 → themeparks-3.2.0}/.gitignore +0 -0
  11. {themeparks-3.1.0 → themeparks-3.2.0}/LICENSE +0 -0
  12. {themeparks-3.1.0 → themeparks-3.2.0}/MIGRATION.md +0 -0
  13. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_cache.py +0 -0
  14. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/__init__.py +0 -0
  15. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/dates.py +0 -0
  16. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/destinations.py +0 -0
  17. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/entity.py +0 -0
  18. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/history.py +0 -0
  19. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_ergonomic/live.py +0 -0
  20. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_errors.py +0 -0
  21. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_generated/__init__.py +0 -0
  22. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_generated/models.py +0 -0
  23. {themeparks-3.1.0 → themeparks-3.2.0}/themeparks/_raw.py +0 -0
  24. {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.1.0
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 longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. |
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 longest `Retry-After` the client will sleep through; past it you get `RateLimitError` instead of a silent wait. |
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "themeparks"
7
- version = "3.1.0"
7
+ version = "3.2.0"
8
8
  description = "Official SDK for the ThemeParks.wiki API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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