getanyapi 0.1.0__py3-none-any.whl

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 (68) hide show
  1. getanyapi/__init__.py +76 -0
  2. getanyapi/_account.py +132 -0
  3. getanyapi/_async_client.py +215 -0
  4. getanyapi/_client.py +274 -0
  5. getanyapi/_errors.py +115 -0
  6. getanyapi/_pagination.py +295 -0
  7. getanyapi/_transport.py +239 -0
  8. getanyapi/platforms/__init__.py +89 -0
  9. getanyapi/platforms/ahrefs.py +384 -0
  10. getanyapi/platforms/airbnb.py +120 -0
  11. getanyapi/platforms/alibaba.py +95 -0
  12. getanyapi/platforms/amazon.py +442 -0
  13. getanyapi/platforms/appstore.py +95 -0
  14. getanyapi/platforms/bluesky.py +215 -0
  15. getanyapi/platforms/booking.py +128 -0
  16. getanyapi/platforms/coinmarketcap.py +113 -0
  17. getanyapi/platforms/congress.py +106 -0
  18. getanyapi/platforms/dexscreener.py +104 -0
  19. getanyapi/platforms/ebay.py +215 -0
  20. getanyapi/platforms/email.py +166 -0
  21. getanyapi/platforms/facebook.py +2324 -0
  22. getanyapi/platforms/fiverr.py +122 -0
  23. getanyapi/platforms/github.py +954 -0
  24. getanyapi/platforms/glassdoor.py +93 -0
  25. getanyapi/platforms/google.py +232 -0
  26. getanyapi/platforms/google_ads.py +380 -0
  27. getanyapi/platforms/google_finance.py +170 -0
  28. getanyapi/platforms/google_shopping.py +103 -0
  29. getanyapi/platforms/hackernews.py +276 -0
  30. getanyapi/platforms/indeed.py +114 -0
  31. getanyapi/platforms/instagram.py +1868 -0
  32. getanyapi/platforms/linkedin.py +1064 -0
  33. getanyapi/platforms/maps.py +412 -0
  34. getanyapi/platforms/pandaexpress.py +262 -0
  35. getanyapi/platforms/person.py +96 -0
  36. getanyapi/platforms/pinterest.py +96 -0
  37. getanyapi/platforms/playstore.py +99 -0
  38. getanyapi/platforms/polymarket.py +109 -0
  39. getanyapi/platforms/realtor.py +104 -0
  40. getanyapi/platforms/reddit.py +582 -0
  41. getanyapi/platforms/redfin.py +95 -0
  42. getanyapi/platforms/rednote.py +807 -0
  43. getanyapi/platforms/sec.py +118 -0
  44. getanyapi/platforms/semrush.py +358 -0
  45. getanyapi/platforms/snapchat.py +146 -0
  46. getanyapi/platforms/social.py +105 -0
  47. getanyapi/platforms/spotify.py +588 -0
  48. getanyapi/platforms/substack.py +142 -0
  49. getanyapi/platforms/threads.py +358 -0
  50. getanyapi/platforms/tiktok.py +1827 -0
  51. getanyapi/platforms/tiktok_shop.py +536 -0
  52. getanyapi/platforms/tripadvisor.py +180 -0
  53. getanyapi/platforms/trustpilot.py +114 -0
  54. getanyapi/platforms/truthsocial.py +226 -0
  55. getanyapi/platforms/twitter.py +798 -0
  56. getanyapi/platforms/upwork.py +119 -0
  57. getanyapi/platforms/walmart.py +93 -0
  58. getanyapi/platforms/web.py +264 -0
  59. getanyapi/platforms/whatsapp.py +91 -0
  60. getanyapi/platforms/yahoo_finance.py +95 -0
  61. getanyapi/platforms/yelp.py +141 -0
  62. getanyapi/platforms/youtube.py +1452 -0
  63. getanyapi/platforms/zillow.py +218 -0
  64. getanyapi/py.typed +0 -0
  65. getanyapi/types.py +170 -0
  66. getanyapi-0.1.0.dist-info/METADATA +155 -0
  67. getanyapi-0.1.0.dist-info/RECORD +68 -0
  68. getanyapi-0.1.0.dist-info/WHEEL +4 -0
getanyapi/_errors.py ADDED
@@ -0,0 +1,115 @@
1
+ """Error hierarchy for the getanyapi SDK (SPEC 3.6).
2
+
3
+ Every failed call raises a subclass of :class:`AnyAPIError`. The HTTP status to
4
+ class mapping is frozen:
5
+
6
+ 400 -> BadRequestError
7
+ 401 -> AuthenticationError
8
+ 402 -> InsufficientBalanceError
9
+ 404 -> NotFoundError
10
+ 429 -> RateLimitedError
11
+ 502 -> UpstreamError
12
+ (any other non-2xx) -> AnyAPIError
13
+
14
+ Transport failures raise :class:`ConnectionError` (status 0); timeouts raise
15
+ :class:`TimeoutError` (status 0). Both names intentionally shadow the Python
16
+ builtins of the same name and are exported from the package root.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __all__ = [
22
+ "AnyAPIError",
23
+ "BadRequestError",
24
+ "AuthenticationError",
25
+ "InsufficientBalanceError",
26
+ "NotFoundError",
27
+ "ResultNotFoundError",
28
+ "RateLimitedError",
29
+ "UpstreamError",
30
+ "ConnectionError",
31
+ "TimeoutError",
32
+ "error_for_status",
33
+ ]
34
+
35
+
36
+ class AnyAPIError(Exception):
37
+ """Base class for every error raised by the SDK.
38
+
39
+ Attributes:
40
+ status: HTTP status code, or 0 for transport-level failures.
41
+ request_id: The x-request-id response header when present, else None.
42
+ """
43
+
44
+ def __init__(
45
+ self, message: str, *, status: int, request_id: str | None = None
46
+ ) -> None:
47
+ super().__init__(message)
48
+ self.status = status
49
+ self.request_id = request_id
50
+
51
+
52
+ class BadRequestError(AnyAPIError):
53
+ """HTTP 400: the input failed validation."""
54
+
55
+
56
+ class AuthenticationError(AnyAPIError):
57
+ """HTTP 401: the API key is missing or invalid."""
58
+
59
+
60
+ class InsufficientBalanceError(AnyAPIError):
61
+ """HTTP 402: the wallet balance or per-key cap was exceeded."""
62
+
63
+
64
+ class NotFoundError(AnyAPIError):
65
+ """HTTP 404: the slug or resource does not exist."""
66
+
67
+
68
+ class ResultNotFoundError(NotFoundError):
69
+ """Raised by :func:`getanyapi.unwrap` when a found-data result had ``found: false``.
70
+
71
+ A subclass of :class:`NotFoundError` so ``except NotFoundError`` still catches
72
+ it; catch ``ResultNotFoundError`` specifically to handle only empty results
73
+ (not HTTP 404s). See SPEC 2.3 erratum.
74
+ """
75
+
76
+
77
+ class RateLimitedError(AnyAPIError):
78
+ """HTTP 429: too many requests. Retried automatically."""
79
+
80
+
81
+ class UpstreamError(AnyAPIError):
82
+ """HTTP 502: an upstream backend failed."""
83
+
84
+
85
+ class ConnectionError(AnyAPIError):
86
+ """A network or transport failure (status 0).
87
+
88
+ This name intentionally shadows the Python builtin ``ConnectionError``.
89
+ """
90
+
91
+
92
+ class TimeoutError(AnyAPIError):
93
+ """A request that exceeded its timeout (status 0).
94
+
95
+ This name intentionally shadows the Python builtin ``TimeoutError``.
96
+ Timeouts are never retried.
97
+ """
98
+
99
+
100
+ _STATUS_MAP: dict[int, type[AnyAPIError]] = {
101
+ 400: BadRequestError,
102
+ 401: AuthenticationError,
103
+ 402: InsufficientBalanceError,
104
+ 404: NotFoundError,
105
+ 429: RateLimitedError,
106
+ 502: UpstreamError,
107
+ }
108
+
109
+
110
+ def error_for_status(
111
+ status: int, message: str, *, request_id: str | None = None
112
+ ) -> AnyAPIError:
113
+ """Build the mapped error for a non-2xx HTTP status (SPEC 3.6)."""
114
+ cls = _STATUS_MAP.get(status, AnyAPIError)
115
+ return cls(message, status=status, request_id=request_id)
@@ -0,0 +1,295 @@
1
+ """Cursor pagination for the sync and async clients (SPEC 2.5, 3.5).
2
+
3
+ A ``Paginator`` walks a paginated SKU one page at a time, yielding either the
4
+ flattened items (``__iter__``) or whole run-result pages (``.pages()``). Items
5
+ are validated into the SKU's pydantic item model, so callers get attribute
6
+ access (``post.title``), not raw dicts (SPEC 3.3 erratum, B3).
7
+
8
+ Walk semantics (frozen):
9
+ * Page 1 is ``run(slug, input, options)``.
10
+ * Read the page data object: for a found-data SKU that is ``output.data``
11
+ (or None when ``found`` is false); for a BARE SKU it is ``output`` itself
12
+ (SPEC 1.2 erratum).
13
+ * Yield each item from ``items_field``. If ``nextCursor`` is a non-empty
14
+ string, set ``input["cursor"] = nextCursor`` and repeat; a null/empty
15
+ ``nextCursor`` ends the walk.
16
+ * ``options["max_items"]`` caps the TOTAL items yielded across all pages.
17
+ It is NOT sent as the wire ``max_items`` shaping param from the iterator;
18
+ the iterator manages paging itself.
19
+
20
+ The emitter targets the module-level helpers :func:`paginate` (sync) and
21
+ :func:`apaginate` (async): each takes the bound client, slug, input dict, the
22
+ items field name, the item model, the page-result model, and the envelope mode.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import copy
28
+ from collections.abc import AsyncIterator, Iterator
29
+ from typing import (
30
+ TYPE_CHECKING,
31
+ Any,
32
+ Generic,
33
+ TypeVar,
34
+ cast,
35
+ )
36
+
37
+ from pydantic import BaseModel
38
+
39
+ from ._transport import as_dict, validate_run_result
40
+ from .types import (
41
+ AnyBareRunResult,
42
+ BareRunResult,
43
+ RequestOptions,
44
+ RunResult,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from ._async_client import AsyncAnyAPI
49
+ from ._client import AnyAPI
50
+
51
+ __all__ = ["Paginator", "AsyncPaginator", "paginate", "apaginate"]
52
+
53
+ Item = TypeVar("Item")
54
+ Data = TypeVar("Data")
55
+
56
+ _NEXT_CURSOR = "nextCursor"
57
+ _CURSOR = "cursor"
58
+
59
+
60
+ def _raw_page_data(raw: dict[str, Any], bare: bool) -> dict[str, Any] | None:
61
+ """The page data dict from the RAW run dict, per envelope, using WIRE keys.
62
+
63
+ For a bare SKU the data IS ``raw["output"]``; for a found-data SKU it is
64
+ ``raw["output"]["data"]`` when ``found`` is true, else None. Reading from the
65
+ raw dict (not the validated model) keeps cursor/items lookups keyed on the
66
+ camelCase wire names even though the model exposes snake_case attributes.
67
+ """
68
+ output = raw.get("output")
69
+ if not isinstance(output, dict):
70
+ return None
71
+ output_dict = cast("dict[str, Any]", output)
72
+ if bare:
73
+ return output_dict
74
+ if output_dict.get("found") is not True:
75
+ return None
76
+ data = output_dict.get("data")
77
+ return cast("dict[str, Any]", data) if isinstance(data, dict) else None
78
+
79
+
80
+ def _field(data: dict[str, Any] | None, key: str) -> object:
81
+ """Read a WIRE-keyed field from the raw page data dict."""
82
+ if data is None:
83
+ return None
84
+ return data.get(key)
85
+
86
+
87
+ def _validate_page(
88
+ raw: dict[str, Any],
89
+ data_model: type[BaseModel] | None,
90
+ bare: bool,
91
+ ) -> RunResult[Any] | BareRunResult[Any]:
92
+ """Validate a raw run dict into the typed page result (found-data or bare)."""
93
+ if bare:
94
+ if data_model is None:
95
+ return AnyBareRunResult.model_validate(raw)
96
+ return BareRunResult[data_model].model_validate(raw) # type: ignore[valid-type]
97
+ if data_model is None:
98
+ return validate_run_result(raw)
99
+ return RunResult[data_model].model_validate(raw) # type: ignore[valid-type]
100
+
101
+
102
+ def _validate_item(item: object, item_model: type[BaseModel] | None) -> Any:
103
+ """Validate one page item into its pydantic model (or pass scalars through)."""
104
+ if item_model is None:
105
+ return item
106
+ return item_model.model_validate(item)
107
+
108
+
109
+ class Paginator(Generic[Item, Data]):
110
+ """Sync cursor walk over a paginated SKU."""
111
+
112
+ def __init__(
113
+ self,
114
+ client: AnyAPI,
115
+ slug: str,
116
+ input: dict[str, Any],
117
+ items_field: str,
118
+ item_model: type[BaseModel] | None,
119
+ data_model: type[BaseModel] | None,
120
+ bare: bool,
121
+ options: Any = None,
122
+ ) -> None:
123
+ self._client = client
124
+ self._slug = slug
125
+ self._input = copy.deepcopy(input)
126
+ self._items_field = items_field
127
+ self._item_model = item_model
128
+ self._data_model = data_model
129
+ self._bare = bare
130
+ self._options = options
131
+ self._cap = _max_items(options)
132
+ self._page_options = _page_options(options)
133
+
134
+ def _walk(
135
+ self,
136
+ ) -> Iterator[tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]]:
137
+ cursor: str | None = None
138
+ while True:
139
+ page_input = dict(self._input)
140
+ if cursor is not None:
141
+ page_input[_CURSOR] = cursor
142
+ raw = self._client._run_raw( # pyright: ignore[reportPrivateUsage]
143
+ self._slug,
144
+ page_input,
145
+ cast("RequestOptions | None", self._page_options),
146
+ )
147
+ page = _validate_page(raw, self._data_model, self._bare)
148
+ data = _raw_page_data(raw, self._bare)
149
+ yield cast("RunResult[Data] | BareRunResult[Data]", page), data
150
+ cursor = _next_cursor(data)
151
+ if cursor is None:
152
+ return
153
+
154
+ def pages(self) -> Iterator[RunResult[Data] | BareRunResult[Data]]:
155
+ """Yield whole run-result pages (read ``cost_usd`` per page)."""
156
+ for page, _ in self._walk():
157
+ yield page
158
+
159
+ def __iter__(self) -> Iterator[Item]:
160
+ count = 0
161
+ for _page, data in self._walk():
162
+ for item in _items(data, self._items_field):
163
+ if self._cap is not None and count >= self._cap:
164
+ return
165
+ yield cast("Item", _validate_item(item, self._item_model))
166
+ count += 1
167
+ if self._cap is not None and count >= self._cap:
168
+ return
169
+
170
+
171
+ class AsyncPaginator(Generic[Item, Data]):
172
+ """Async cursor walk over a paginated SKU."""
173
+
174
+ def __init__(
175
+ self,
176
+ client: AsyncAnyAPI,
177
+ slug: str,
178
+ input: dict[str, Any],
179
+ items_field: str,
180
+ item_model: type[BaseModel] | None,
181
+ data_model: type[BaseModel] | None,
182
+ bare: bool,
183
+ options: Any = None,
184
+ ) -> None:
185
+ self._client = client
186
+ self._slug = slug
187
+ self._input = copy.deepcopy(input)
188
+ self._items_field = items_field
189
+ self._item_model = item_model
190
+ self._data_model = data_model
191
+ self._bare = bare
192
+ self._options = options
193
+ self._cap = _max_items(options)
194
+ self._page_options = _page_options(options)
195
+
196
+ async def _walk(
197
+ self,
198
+ ) -> AsyncIterator[
199
+ tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]
200
+ ]:
201
+ cursor: str | None = None
202
+ while True:
203
+ page_input = dict(self._input)
204
+ if cursor is not None:
205
+ page_input[_CURSOR] = cursor
206
+ raw = await self._client._arun_raw( # pyright: ignore[reportPrivateUsage]
207
+ self._slug,
208
+ page_input,
209
+ cast("RequestOptions | None", self._page_options),
210
+ )
211
+ page = _validate_page(raw, self._data_model, self._bare)
212
+ data = _raw_page_data(raw, self._bare)
213
+ yield cast("RunResult[Data] | BareRunResult[Data]", page), data
214
+ cursor = _next_cursor(data)
215
+ if cursor is None:
216
+ return
217
+
218
+ async def pages(self) -> AsyncIterator[RunResult[Data] | BareRunResult[Data]]:
219
+ """Yield whole run-result pages (read ``cost_usd`` per page)."""
220
+ async for page, _ in self._walk():
221
+ yield page
222
+
223
+ async def __aiter__(self) -> AsyncIterator[Item]:
224
+ count = 0
225
+ async for _page, data in self._walk():
226
+ for item in _items(data, self._items_field):
227
+ if self._cap is not None and count >= self._cap:
228
+ return
229
+ yield cast("Item", _validate_item(item, self._item_model))
230
+ count += 1
231
+ if self._cap is not None and count >= self._cap:
232
+ return
233
+
234
+
235
+ def _items(data: dict[str, Any] | None, items_field: str) -> list[Any]:
236
+ raw_items = _field(data, items_field)
237
+ if isinstance(raw_items, (list, tuple)):
238
+ return list(cast("list[Any]", raw_items))
239
+ return []
240
+
241
+
242
+ def _next_cursor(data: dict[str, Any] | None) -> str | None:
243
+ next_cursor = _field(data, _NEXT_CURSOR)
244
+ if isinstance(next_cursor, str) and next_cursor:
245
+ return next_cursor
246
+ return None
247
+
248
+
249
+ def _max_items(options: object) -> int | None:
250
+ cap = as_dict(options).get("max_items")
251
+ return cap if isinstance(cap, int) else None
252
+
253
+
254
+ def _page_options(options: object) -> object:
255
+ """Strip max_items before sending: the iterator paces itself (SPEC 2.5)."""
256
+ if not isinstance(options, dict):
257
+ return options
258
+ source = as_dict(cast("object", options))
259
+ if "max_items" not in source:
260
+ return source or None
261
+ stripped: dict[str, object] = dict(source)
262
+ stripped.pop("max_items", None)
263
+ return stripped or None
264
+
265
+
266
+ def paginate(
267
+ client: AnyAPI,
268
+ slug: str,
269
+ input: dict[str, Any],
270
+ items_field: str,
271
+ item_model: type[BaseModel] | None = None,
272
+ data_model: type[BaseModel] | None = None,
273
+ bare: bool = False,
274
+ options: Any = None,
275
+ ) -> Paginator[Any, Any]:
276
+ """Build a sync Paginator bound to the client's run seam (emitter target)."""
277
+ return Paginator(
278
+ client, slug, input, items_field, item_model, data_model, bare, options
279
+ )
280
+
281
+
282
+ def apaginate(
283
+ client: AsyncAnyAPI,
284
+ slug: str,
285
+ input: dict[str, Any],
286
+ items_field: str,
287
+ item_model: type[BaseModel] | None = None,
288
+ data_model: type[BaseModel] | None = None,
289
+ bare: bool = False,
290
+ options: Any = None,
291
+ ) -> AsyncPaginator[Any, Any]:
292
+ """Build an async Paginator bound to the client's run seam (emitter target)."""
293
+ return AsyncPaginator(
294
+ client, slug, input, items_field, item_model, data_model, bare, options
295
+ )
@@ -0,0 +1,239 @@
1
+ """Shared wire and retry engine for the sync and async clients (SPEC 2.2, 2.8).
2
+
3
+ Both :class:`getanyapi.AnyAPI` and :class:`getanyapi.AsyncAnyAPI` route every SKU run
4
+ through here. The wire contract is frozen:
5
+
6
+ POST {base_url}/v1/run/{slug}
7
+ Authorization: Bearer <api_key>
8
+ Content-Type: application/json
9
+ Accept: application/json
10
+ body = json(input)
11
+ query params (only when set): fields (comma-joined), max_items, summary=true
12
+
13
+ HTTP 200 parses into ``RunResult[Any]``; any other status maps to the frozen
14
+ error hierarchy. Retries cover only HTTP 429 and network failures, never
15
+ timeouts, with jittered exponential backoff honoring ``Retry-After`` on 429.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import email.utils
21
+ import random
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from typing import Any, cast
25
+
26
+ import httpx
27
+ from pydantic import ValidationError
28
+
29
+ from ._errors import (
30
+ AnyAPIError,
31
+ ConnectionError,
32
+ RateLimitedError,
33
+ TimeoutError,
34
+ error_for_status,
35
+ )
36
+ from .types import RequestOptions, RunResult
37
+
38
+ __all__ = [
39
+ "build_request",
40
+ "parse_raw",
41
+ "validate_run_result",
42
+ "compute_delay",
43
+ "RetryState",
44
+ "error_message",
45
+ "as_dict",
46
+ "is_retryable_error",
47
+ "sleep",
48
+ ]
49
+
50
+ _BASE_DELAY = 0.5 # seconds
51
+ _MAX_DELAY = 8.0 # seconds
52
+ _REQUEST_ID_HEADER = "x-request-id"
53
+ _RNG = random.Random()
54
+
55
+
56
+ def _query_params(options: RequestOptions | None) -> dict[str, str]:
57
+ """Build the response-shaping query params from options (SPEC 2.2)."""
58
+ params: dict[str, str] = {}
59
+ if not options:
60
+ return params
61
+ fields = options.get("fields")
62
+ if fields:
63
+ params["fields"] = ",".join(fields)
64
+ max_items = options.get("max_items")
65
+ if max_items is not None:
66
+ params["max_items"] = str(max_items)
67
+ if options.get("summary"):
68
+ params["summary"] = "true"
69
+ return params
70
+
71
+
72
+ def build_request(
73
+ *,
74
+ base_url: str,
75
+ slug: str,
76
+ input: dict[str, Any],
77
+ api_key: str,
78
+ options: RequestOptions | None,
79
+ timeout: float,
80
+ ) -> httpx.Request:
81
+ """Assemble the httpx.Request for a SKU run (no client bound).
82
+
83
+ The per-request timeout is carried on the request's ``extensions`` so it
84
+ applies on ``client.send(request)`` without depending on the client default.
85
+ """
86
+ url = f"{base_url.rstrip('/')}/v1/run/{slug}"
87
+ headers = {
88
+ "Authorization": f"Bearer {api_key}",
89
+ "Content-Type": "application/json",
90
+ "Accept": "application/json",
91
+ }
92
+ return httpx.Request(
93
+ "POST",
94
+ url,
95
+ params=_query_params(options),
96
+ headers=headers,
97
+ json=input,
98
+ extensions={"timeout": httpx.Timeout(timeout).as_dict()},
99
+ )
100
+
101
+
102
+ def _fallback_message(status: int) -> str:
103
+ return f"request failed with status {status}"
104
+
105
+
106
+ def as_dict(value: object) -> dict[str, object]:
107
+ """Narrow an arbitrary JSON value to a str-keyed dict (empty if not one)."""
108
+ if isinstance(value, dict):
109
+ return cast("dict[str, object]", value)
110
+ return {}
111
+
112
+
113
+ def error_message(body: object, status: int) -> str:
114
+ """Extract the ``{error}`` string from a JSON body, else a generic message."""
115
+ err = as_dict(body).get("error")
116
+ if isinstance(err, str):
117
+ return err
118
+ return _fallback_message(status)
119
+
120
+
121
+ def parse_raw(response: httpx.Response) -> dict[str, Any]:
122
+ """Return the parsed JSON dict on 200, or raise the mapped error otherwise.
123
+
124
+ The raw-dict seam (SPEC N2): generated methods validate this dict directly
125
+ into their concrete ``RunResult[XData]`` / ``BareRunResult[XData]`` model, so
126
+ there is no model_validate(model_dump(...)) double-parse. The bare-vs-found
127
+ envelope choice is the caller's (the generated code knows its SKU's shape).
128
+ """
129
+ request_id = response.headers.get(_REQUEST_ID_HEADER)
130
+ if response.status_code == 200:
131
+ try:
132
+ body = response.json()
133
+ except ValueError as exc:
134
+ raise AnyAPIError(
135
+ f"could not parse run response: {exc}",
136
+ status=200,
137
+ request_id=request_id,
138
+ ) from exc
139
+ if not isinstance(body, dict):
140
+ raise AnyAPIError(
141
+ "run response was not a JSON object",
142
+ status=200,
143
+ request_id=request_id,
144
+ )
145
+ return cast("dict[str, Any]", body)
146
+
147
+ err_body: object = None
148
+ try:
149
+ err_body = response.json()
150
+ except ValueError:
151
+ err_body = None
152
+ message = error_message(err_body, response.status_code)
153
+ raise error_for_status(
154
+ response.status_code, message, request_id=request_id
155
+ )
156
+
157
+
158
+ def validate_run_result(raw: dict[str, Any]) -> RunResult[Any]:
159
+ """Validate a raw run dict into a generic ``RunResult[Any]`` (found-data).
160
+
161
+ Used by the generic ``client.run(slug, ...)`` helper. Bare SKUs are best
162
+ reached through their typed namespace method (which validates into a
163
+ ``BareRunResult``); the generic path assumes the found-data envelope.
164
+ """
165
+ try:
166
+ return RunResult[Any].model_validate(raw)
167
+ except ValidationError as exc:
168
+ raise AnyAPIError(
169
+ f"could not parse run response: {exc}", status=200
170
+ ) from exc
171
+
172
+
173
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
174
+ """Parse a Retry-After header (seconds or HTTP-date), capped at max delay."""
175
+ raw = response.headers.get("retry-after")
176
+ if not raw:
177
+ return None
178
+ raw = raw.strip()
179
+ try:
180
+ secs = float(raw)
181
+ except ValueError:
182
+ secs = None
183
+ if secs is not None:
184
+ return min(max(secs, 0.0), _MAX_DELAY)
185
+ try:
186
+ parsed = email.utils.parsedate_to_datetime(raw)
187
+ except (TypeError, ValueError):
188
+ return None
189
+ if parsed.tzinfo is None:
190
+ parsed = parsed.replace(tzinfo=timezone.utc)
191
+ delta = (parsed - datetime.now(timezone.utc)).total_seconds()
192
+ return min(max(delta, 0.0), _MAX_DELAY)
193
+
194
+
195
+ def compute_delay(attempt: int, rng: random.Random | None = None) -> float:
196
+ """Jittered exponential backoff for retry ``attempt`` (0-based) (SPEC 2.8)."""
197
+ r: random.Random = rng or _RNG
198
+ base = min(_BASE_DELAY * float(2 ** attempt), _MAX_DELAY)
199
+ jitter = 0.5 + r.random()
200
+ return base * jitter
201
+
202
+
203
+ class RetryState:
204
+ """Tracks retry budget and computes the next delay across attempts.
205
+
206
+ Shared by the sync and async run loops so the retry policy lives in one
207
+ place. The caller drives the loop; this object only decides whether another
208
+ attempt is allowed and how long to wait.
209
+ """
210
+
211
+ def __init__(self, max_retries: int) -> None:
212
+ self.max_retries = max(0, max_retries)
213
+ self.attempt = 0
214
+
215
+ @property
216
+ def can_retry(self) -> bool:
217
+ return self.attempt < self.max_retries
218
+
219
+ def next_delay(self, response: httpx.Response | None) -> float:
220
+ """Delay before the next retry; honors Retry-After on a 429 response."""
221
+ delay = compute_delay(self.attempt)
222
+ if response is not None:
223
+ retry_after = _retry_after_seconds(response)
224
+ if retry_after is not None:
225
+ delay = retry_after
226
+ self.attempt += 1
227
+ return delay
228
+
229
+
230
+ def is_retryable_error(exc: AnyAPIError) -> bool:
231
+ """Retry only rate limits and connection failures, never timeouts."""
232
+ if isinstance(exc, TimeoutError):
233
+ return False
234
+ return isinstance(exc, (RateLimitedError, ConnectionError))
235
+
236
+
237
+ def sleep(seconds: float) -> None:
238
+ """Blocking sleep seam (monkeypatched in tests)."""
239
+ time.sleep(seconds)