getanyapi 0.9.2__tar.gz → 0.9.6__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.
- {getanyapi-0.9.2 → getanyapi-0.9.6}/PKG-INFO +15 -5
- {getanyapi-0.9.2 → getanyapi-0.9.6}/README.md +14 -4
- {getanyapi-0.9.2 → getanyapi-0.9.6}/pyproject.toml +1 -1
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/__init__.py +1 -1
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_account.py +5 -2
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_async_client.py +34 -4
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_client.py +34 -4
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_errors.py +14 -3
- getanyapi-0.9.6/src/getanyapi/_idempotency.py +34 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_pagination.py +26 -2
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_transport.py +64 -12
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/bluesky.py +2 -2
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/email.py +12 -6
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/facebook.py +33 -35
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/github.py +10 -20
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/instagram.py +38 -52
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/linkedin.py +4 -6
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/reddit.py +12 -20
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tiktok.py +40 -60
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tiktok_shop.py +6 -10
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/twitter.py +10 -20
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/youtube.py +32 -42
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/types.py +2 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_errors.py +16 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_pagination.py +40 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_transport.py +171 -5
- {getanyapi-0.9.2 → getanyapi-0.9.6}/.gitignore +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/__init__.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/ahrefs.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/airbnb.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/alibaba.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/amazon.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/apollo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/appstore.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/booking.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/coinmarketcap.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/congress.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/dexscreener.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/douyin.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/ebay.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/fiverr.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/glassdoor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_ads.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_finance.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_shopping.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/hackernews.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/indeed.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/maps.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/pandaexpress.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/person.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/pinterest.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/playstore.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/polymarket.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/realtor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/redfin.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/rednote.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/sec.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/semrush.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/seo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/snapchat.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/social.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/spotify.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/substack.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/threads.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tripadvisor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/trustpilot.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/truthsocial.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/upwork.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/walmart.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/web.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/weibo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/whatsapp.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/yahoo_finance.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/yelp.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/zhihu.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/zillow.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/py.typed +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/conftest.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_account.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_client.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_envelope.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_fixture_roundtrip.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_fixture_sweep.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: getanyapi
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.6
|
|
4
4
|
Summary: Official typed Python SDK for AnyAPI: any API, one wallet, USD, no subscriptions.
|
|
5
5
|
Project-URL: Homepage, https://getanyapi.com
|
|
6
6
|
Project-URL: Documentation, https://getanyapi.com/docs
|
|
@@ -138,13 +138,23 @@ res = client.google.search(
|
|
|
138
138
|
| `ResultNotFoundError` | - | `unwrap` on an empty found-data result |
|
|
139
139
|
| `RateLimitedError` | 429 | Too many requests (retried automatically) |
|
|
140
140
|
| `UpstreamError` | 502 | An upstream backend failed |
|
|
141
|
-
| `ConnectionError` | 0 | Network or transport failure
|
|
141
|
+
| `ConnectionError` | 0 | Network or transport failure |
|
|
142
142
|
| `TimeoutError` | 0 | Request exceeded its timeout (not retried) |
|
|
143
143
|
|
|
144
144
|
All extend `AnyAPIError` (with `.status` and `.request_id`). Retries cover only 429 and network
|
|
145
|
-
failures, with jittered exponential backoff honoring
|
|
146
|
-
(up to 3 attempts); set it on the client
|
|
147
|
-
`options`. Timeouts are never retried.
|
|
145
|
+
failures proven to happen before a request was sent, with jittered exponential backoff honoring
|
|
146
|
+
`Retry-After`. Default `max_retries` is 2 (up to 3 attempts); set it on the client
|
|
147
|
+
(`AnyAPI(max_retries=...)`) or per request via `options`. Timeouts are never retried. Connection
|
|
148
|
+
failures during or after a billed `POST /v1/run` are not retried because the call may already have
|
|
149
|
+
been charged. When the send phase is unknown, the SDK does not retry.
|
|
150
|
+
|
|
151
|
+
Automatic retry of a billed `run()` requires structured transport evidence that the request was
|
|
152
|
+
not delivered. Python's `httpx` transport provides that evidence for `ConnectError`, so DNS
|
|
153
|
+
resolution failures and refused or unreachable connections retry. `ConnectTimeout` is a
|
|
154
|
+
`TimeoutException` and does not retry. `ReadError` does not retry: it can mean either that a
|
|
155
|
+
connection closed before the server read the request or that the connection failed after delivery.
|
|
156
|
+
If a custom transport hides the connection phase, handle retries explicitly and only when your
|
|
157
|
+
application can establish non-delivery.
|
|
148
158
|
|
|
149
159
|
## Agent signup
|
|
150
160
|
|
|
@@ -109,13 +109,23 @@ res = client.google.search(
|
|
|
109
109
|
| `ResultNotFoundError` | - | `unwrap` on an empty found-data result |
|
|
110
110
|
| `RateLimitedError` | 429 | Too many requests (retried automatically) |
|
|
111
111
|
| `UpstreamError` | 502 | An upstream backend failed |
|
|
112
|
-
| `ConnectionError` | 0 | Network or transport failure
|
|
112
|
+
| `ConnectionError` | 0 | Network or transport failure |
|
|
113
113
|
| `TimeoutError` | 0 | Request exceeded its timeout (not retried) |
|
|
114
114
|
|
|
115
115
|
All extend `AnyAPIError` (with `.status` and `.request_id`). Retries cover only 429 and network
|
|
116
|
-
failures, with jittered exponential backoff honoring
|
|
117
|
-
(up to 3 attempts); set it on the client
|
|
118
|
-
`options`. Timeouts are never retried.
|
|
116
|
+
failures proven to happen before a request was sent, with jittered exponential backoff honoring
|
|
117
|
+
`Retry-After`. Default `max_retries` is 2 (up to 3 attempts); set it on the client
|
|
118
|
+
(`AnyAPI(max_retries=...)`) or per request via `options`. Timeouts are never retried. Connection
|
|
119
|
+
failures during or after a billed `POST /v1/run` are not retried because the call may already have
|
|
120
|
+
been charged. When the send phase is unknown, the SDK does not retry.
|
|
121
|
+
|
|
122
|
+
Automatic retry of a billed `run()` requires structured transport evidence that the request was
|
|
123
|
+
not delivered. Python's `httpx` transport provides that evidence for `ConnectError`, so DNS
|
|
124
|
+
resolution failures and refused or unreachable connections retry. `ConnectTimeout` is a
|
|
125
|
+
`TimeoutException` and does not retry. `ReadError` does not retry: it can mean either that a
|
|
126
|
+
connection closed before the server read the request or that the connection failed after delivery.
|
|
127
|
+
If a custom transport hides the connection phase, handle retries explicitly and only when your
|
|
128
|
+
application can establish non-delivery.
|
|
119
129
|
|
|
120
130
|
## Agent signup
|
|
121
131
|
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
from typing import Any, cast
|
|
12
12
|
|
|
13
13
|
from ._errors import AnyAPIError, error_for_status
|
|
14
|
-
from ._transport import as_dict,
|
|
14
|
+
from ._transport import as_dict, error_details
|
|
15
15
|
from .types import (
|
|
16
16
|
AccountProfile,
|
|
17
17
|
AgentSignupResult,
|
|
@@ -141,4 +141,7 @@ def parse_signup(body: Any) -> AgentSignupResult:
|
|
|
141
141
|
|
|
142
142
|
def map_error(status: int, body: object, request_id: str | None) -> AnyAPIError:
|
|
143
143
|
"""Map a non-2xx account/catalog response to the frozen error hierarchy."""
|
|
144
|
-
|
|
144
|
+
message, code = error_details(body, status)
|
|
145
|
+
return error_for_status(
|
|
146
|
+
status, message, request_id=request_id, code=code
|
|
147
|
+
)
|
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import importlib
|
|
14
14
|
import os
|
|
15
|
-
from typing import Any
|
|
15
|
+
from typing import Any, Literal
|
|
16
16
|
|
|
17
17
|
import httpx
|
|
18
18
|
|
|
@@ -57,12 +57,16 @@ class AsyncAnyAPI:
|
|
|
57
57
|
base_url: str = _DEFAULT_BASE_URL,
|
|
58
58
|
timeout: float = 60.0,
|
|
59
59
|
max_retries: int = 2,
|
|
60
|
+
idempotency: Literal["auto", "off"] = "auto",
|
|
60
61
|
http_client: httpx.AsyncClient | None = None,
|
|
61
62
|
) -> None:
|
|
62
63
|
self._api_key = _resolve_api_key(api_key)
|
|
63
64
|
self._base_url = base_url.rstrip("/")
|
|
64
65
|
self._timeout = timeout
|
|
65
66
|
self._max_retries = max_retries
|
|
67
|
+
if idempotency not in ("auto", "off"):
|
|
68
|
+
raise ValueError('idempotency must be "auto" or "off"')
|
|
69
|
+
self._idempotency = idempotency
|
|
66
70
|
self._owns_client = http_client is None
|
|
67
71
|
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
68
72
|
self._namespaces: dict[str, Any] = {}
|
|
@@ -115,6 +119,7 @@ class AsyncAnyAPI:
|
|
|
115
119
|
api_key=self._api_key,
|
|
116
120
|
options=options,
|
|
117
121
|
timeout=timeout,
|
|
122
|
+
idempotency=self._idempotency,
|
|
118
123
|
)
|
|
119
124
|
retry = RetryState(max_retries)
|
|
120
125
|
while True:
|
|
@@ -130,7 +135,10 @@ class AsyncAnyAPI:
|
|
|
130
135
|
except httpx.TimeoutException as exc:
|
|
131
136
|
raise TimeoutError(str(exc) or "request timed out", status=0) from exc
|
|
132
137
|
except httpx.HTTPError as exc:
|
|
133
|
-
if
|
|
138
|
+
if (
|
|
139
|
+
is_retryable_error(exc, request_may_be_billed=True)
|
|
140
|
+
and retry.can_retry
|
|
141
|
+
):
|
|
134
142
|
await asyncio.sleep(retry.next_delay(None))
|
|
135
143
|
continue
|
|
136
144
|
raise ConnectionError(
|
|
@@ -165,8 +173,30 @@ class AsyncAnyAPI:
|
|
|
165
173
|
"Authorization": f"Bearer {self._api_key}",
|
|
166
174
|
"Accept": "application/json",
|
|
167
175
|
}
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
retry = RetryState(self._max_retries)
|
|
177
|
+
while True:
|
|
178
|
+
response: httpx.Response | None = None
|
|
179
|
+
try:
|
|
180
|
+
response = await self._http.get(
|
|
181
|
+
url, params=params or {}, headers=headers
|
|
182
|
+
)
|
|
183
|
+
return self._json_or_raise(response)
|
|
184
|
+
except AnyAPIError as exc:
|
|
185
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
186
|
+
await asyncio.sleep(retry.next_delay(response))
|
|
187
|
+
continue
|
|
188
|
+
raise
|
|
189
|
+
except httpx.TimeoutException as exc:
|
|
190
|
+
raise TimeoutError(
|
|
191
|
+
str(exc) or "request timed out", status=0
|
|
192
|
+
) from exc
|
|
193
|
+
except httpx.HTTPError as exc:
|
|
194
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
195
|
+
await asyncio.sleep(retry.next_delay(None))
|
|
196
|
+
continue
|
|
197
|
+
raise ConnectionError(
|
|
198
|
+
str(exc) or "connection failed", status=0
|
|
199
|
+
) from exc
|
|
170
200
|
|
|
171
201
|
def _json_or_raise(self, response: httpx.Response) -> object:
|
|
172
202
|
request_id = response.headers.get("x-request-id")
|
|
@@ -32,7 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
|
|
33
33
|
import importlib
|
|
34
34
|
import os
|
|
35
|
-
from typing import Any
|
|
35
|
+
from typing import Any, Literal
|
|
36
36
|
|
|
37
37
|
import httpx
|
|
38
38
|
|
|
@@ -91,12 +91,16 @@ class AnyAPI:
|
|
|
91
91
|
base_url: str = _DEFAULT_BASE_URL,
|
|
92
92
|
timeout: float = 60.0,
|
|
93
93
|
max_retries: int = 2,
|
|
94
|
+
idempotency: Literal["auto", "off"] = "auto",
|
|
94
95
|
http_client: httpx.Client | None = None,
|
|
95
96
|
) -> None:
|
|
96
97
|
self._api_key = _resolve_api_key(api_key)
|
|
97
98
|
self._base_url = base_url.rstrip("/")
|
|
98
99
|
self._timeout = timeout
|
|
99
100
|
self._max_retries = max_retries
|
|
101
|
+
if idempotency not in ("auto", "off"):
|
|
102
|
+
raise ValueError('idempotency must be "auto" or "off"')
|
|
103
|
+
self._idempotency = idempotency
|
|
100
104
|
self._owns_client = http_client is None
|
|
101
105
|
self._http = http_client or httpx.Client(timeout=timeout)
|
|
102
106
|
self._namespaces: dict[str, Any] = {}
|
|
@@ -150,6 +154,7 @@ class AnyAPI:
|
|
|
150
154
|
api_key=self._api_key,
|
|
151
155
|
options=options,
|
|
152
156
|
timeout=timeout,
|
|
157
|
+
idempotency=self._idempotency,
|
|
153
158
|
)
|
|
154
159
|
retry = RetryState(max_retries)
|
|
155
160
|
while True:
|
|
@@ -165,7 +170,10 @@ class AnyAPI:
|
|
|
165
170
|
except httpx.TimeoutException as exc:
|
|
166
171
|
raise TimeoutError(str(exc) or "request timed out", status=0) from exc
|
|
167
172
|
except httpx.HTTPError as exc:
|
|
168
|
-
if
|
|
173
|
+
if (
|
|
174
|
+
is_retryable_error(exc, request_may_be_billed=True)
|
|
175
|
+
and retry.can_retry
|
|
176
|
+
):
|
|
169
177
|
_transport.sleep(retry.next_delay(None))
|
|
170
178
|
continue
|
|
171
179
|
raise ConnectionError(
|
|
@@ -200,8 +208,30 @@ class AnyAPI:
|
|
|
200
208
|
"Authorization": f"Bearer {self._api_key}",
|
|
201
209
|
"Accept": "application/json",
|
|
202
210
|
}
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
retry = RetryState(self._max_retries)
|
|
212
|
+
while True:
|
|
213
|
+
response: httpx.Response | None = None
|
|
214
|
+
try:
|
|
215
|
+
response = self._http.get(
|
|
216
|
+
url, params=params or {}, headers=headers
|
|
217
|
+
)
|
|
218
|
+
return self._json_or_raise(response)
|
|
219
|
+
except AnyAPIError as exc:
|
|
220
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
221
|
+
_transport.sleep(retry.next_delay(response))
|
|
222
|
+
continue
|
|
223
|
+
raise
|
|
224
|
+
except httpx.TimeoutException as exc:
|
|
225
|
+
raise TimeoutError(
|
|
226
|
+
str(exc) or "request timed out", status=0
|
|
227
|
+
) from exc
|
|
228
|
+
except httpx.HTTPError as exc:
|
|
229
|
+
if is_retryable_error(exc) and retry.can_retry:
|
|
230
|
+
_transport.sleep(retry.next_delay(None))
|
|
231
|
+
continue
|
|
232
|
+
raise ConnectionError(
|
|
233
|
+
str(exc) or "connection failed", status=0
|
|
234
|
+
) from exc
|
|
205
235
|
|
|
206
236
|
def _json_or_raise(self, response: httpx.Response) -> object:
|
|
207
237
|
request_id = response.headers.get("x-request-id")
|
|
@@ -39,14 +39,21 @@ class AnyAPIError(Exception):
|
|
|
39
39
|
Attributes:
|
|
40
40
|
status: HTTP status code, or 0 for transport-level failures.
|
|
41
41
|
request_id: The x-request-id response header when present, else None.
|
|
42
|
+
code: Stable gateway error code when the JSON body includes one, else None.
|
|
42
43
|
"""
|
|
43
44
|
|
|
44
45
|
def __init__(
|
|
45
|
-
self,
|
|
46
|
+
self,
|
|
47
|
+
message: str,
|
|
48
|
+
*,
|
|
49
|
+
status: int,
|
|
50
|
+
request_id: str | None = None,
|
|
51
|
+
code: str | None = None,
|
|
46
52
|
) -> None:
|
|
47
53
|
super().__init__(message)
|
|
48
54
|
self.status = status
|
|
49
55
|
self.request_id = request_id
|
|
56
|
+
self.code = code
|
|
50
57
|
|
|
51
58
|
|
|
52
59
|
class BadRequestError(AnyAPIError):
|
|
@@ -108,8 +115,12 @@ _STATUS_MAP: dict[int, type[AnyAPIError]] = {
|
|
|
108
115
|
|
|
109
116
|
|
|
110
117
|
def error_for_status(
|
|
111
|
-
status: int,
|
|
118
|
+
status: int,
|
|
119
|
+
message: str,
|
|
120
|
+
*,
|
|
121
|
+
request_id: str | None = None,
|
|
122
|
+
code: str | None = None,
|
|
112
123
|
) -> AnyAPIError:
|
|
113
124
|
"""Build the mapped error for a non-2xx HTTP status (SPEC 3.6)."""
|
|
114
125
|
cls = _STATUS_MAP.get(status, AnyAPIError)
|
|
115
|
-
return cls(message, status=status, request_id=request_id)
|
|
126
|
+
return cls(message, status=status, request_id=request_id, code=code)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Idempotency-key generation and pagination derivation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
_MAX_KEY_BYTES = 255
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def generate_idempotency_key() -> str:
|
|
11
|
+
"""Return a stdlib UUID token without separators."""
|
|
12
|
+
return uuid.uuid4().hex
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_idempotency_key(key: str) -> None:
|
|
16
|
+
"""Enforce the gateway's 1-255 byte visible-ASCII wire contract."""
|
|
17
|
+
if (
|
|
18
|
+
not key
|
|
19
|
+
or len(key) > _MAX_KEY_BYTES
|
|
20
|
+
or any(ord(char) < 0x21 or ord(char) > 0x7E for char in key)
|
|
21
|
+
):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"idempotency_key must be 1-255 bytes of visible ASCII (0x21-0x7e)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def page_idempotency_key(key: str, page_number: int) -> str:
|
|
28
|
+
"""Derive a distinct, valid key for one explicitly keyed page."""
|
|
29
|
+
validate_idempotency_key(key)
|
|
30
|
+
suffix = f"-p{page_number}"
|
|
31
|
+
prefix_length = _MAX_KEY_BYTES - len(suffix)
|
|
32
|
+
if prefix_length < 1:
|
|
33
|
+
raise ValueError("pagination page number is too large for an idempotency key")
|
|
34
|
+
return f"{key[:prefix_length]}{suffix}"
|
|
@@ -36,6 +36,7 @@ from typing import (
|
|
|
36
36
|
|
|
37
37
|
from pydantic import BaseModel
|
|
38
38
|
|
|
39
|
+
from ._idempotency import page_idempotency_key
|
|
39
40
|
from ._transport import as_dict, validate_run_result
|
|
40
41
|
from .types import (
|
|
41
42
|
AnyBareRunResult,
|
|
@@ -135,6 +136,7 @@ class Paginator(Generic[Item, Data]):
|
|
|
135
136
|
self,
|
|
136
137
|
) -> Iterator[tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]]:
|
|
137
138
|
cursor: str | None = None
|
|
139
|
+
page_number = 1
|
|
138
140
|
while True:
|
|
139
141
|
page_input = dict(self._input)
|
|
140
142
|
if cursor is not None:
|
|
@@ -142,7 +144,10 @@ class Paginator(Generic[Item, Data]):
|
|
|
142
144
|
raw = self._client._run_raw( # pyright: ignore[reportPrivateUsage]
|
|
143
145
|
self._slug,
|
|
144
146
|
page_input,
|
|
145
|
-
cast(
|
|
147
|
+
cast(
|
|
148
|
+
"RequestOptions | None",
|
|
149
|
+
_options_for_page(self._page_options, page_number),
|
|
150
|
+
),
|
|
146
151
|
)
|
|
147
152
|
page = _validate_page(raw, self._data_model, self._bare)
|
|
148
153
|
data = _raw_page_data(raw, self._bare)
|
|
@@ -150,6 +155,7 @@ class Paginator(Generic[Item, Data]):
|
|
|
150
155
|
cursor = _next_cursor(data)
|
|
151
156
|
if cursor is None:
|
|
152
157
|
return
|
|
158
|
+
page_number += 1
|
|
153
159
|
|
|
154
160
|
def pages(self) -> Iterator[RunResult[Data] | BareRunResult[Data]]:
|
|
155
161
|
"""Yield whole run-result pages (read ``cost_usd`` per page)."""
|
|
@@ -199,6 +205,7 @@ class AsyncPaginator(Generic[Item, Data]):
|
|
|
199
205
|
tuple[RunResult[Data] | BareRunResult[Data], dict[str, Any] | None]
|
|
200
206
|
]:
|
|
201
207
|
cursor: str | None = None
|
|
208
|
+
page_number = 1
|
|
202
209
|
while True:
|
|
203
210
|
page_input = dict(self._input)
|
|
204
211
|
if cursor is not None:
|
|
@@ -206,7 +213,10 @@ class AsyncPaginator(Generic[Item, Data]):
|
|
|
206
213
|
raw = await self._client._arun_raw( # pyright: ignore[reportPrivateUsage]
|
|
207
214
|
self._slug,
|
|
208
215
|
page_input,
|
|
209
|
-
cast(
|
|
216
|
+
cast(
|
|
217
|
+
"RequestOptions | None",
|
|
218
|
+
_options_for_page(self._page_options, page_number),
|
|
219
|
+
),
|
|
210
220
|
)
|
|
211
221
|
page = _validate_page(raw, self._data_model, self._bare)
|
|
212
222
|
data = _raw_page_data(raw, self._bare)
|
|
@@ -214,6 +224,7 @@ class AsyncPaginator(Generic[Item, Data]):
|
|
|
214
224
|
cursor = _next_cursor(data)
|
|
215
225
|
if cursor is None:
|
|
216
226
|
return
|
|
227
|
+
page_number += 1
|
|
217
228
|
|
|
218
229
|
async def pages(self) -> AsyncIterator[RunResult[Data] | BareRunResult[Data]]:
|
|
219
230
|
"""Yield whole run-result pages (read ``cost_usd`` per page)."""
|
|
@@ -263,6 +274,19 @@ def _page_options(options: object) -> object:
|
|
|
263
274
|
return stripped or None
|
|
264
275
|
|
|
265
276
|
|
|
277
|
+
def _options_for_page(options: object, page_number: int) -> object:
|
|
278
|
+
"""Derive a distinct explicit key for one page, preserving other options."""
|
|
279
|
+
if not isinstance(options, dict):
|
|
280
|
+
return options
|
|
281
|
+
source = as_dict(cast("object", options))
|
|
282
|
+
key = source.get("idempotency_key")
|
|
283
|
+
if not isinstance(key, str):
|
|
284
|
+
return source
|
|
285
|
+
page_options: dict[str, object] = dict(source)
|
|
286
|
+
page_options["idempotency_key"] = page_idempotency_key(key, page_number)
|
|
287
|
+
return page_options
|
|
288
|
+
|
|
289
|
+
|
|
266
290
|
def paginate(
|
|
267
291
|
client: AnyAPI,
|
|
268
292
|
slug: str,
|
|
@@ -7,12 +7,17 @@ through here. The wire contract is frozen:
|
|
|
7
7
|
Authorization: Bearer <api_key>
|
|
8
8
|
Content-Type: application/json
|
|
9
9
|
Accept: application/json
|
|
10
|
+
Idempotency-Key: <per-call key> (unless the client kill switch is off)
|
|
10
11
|
body = json(input)
|
|
11
12
|
query params (only when set): fields (comma-joined), max_items, summary=true
|
|
12
13
|
|
|
13
14
|
HTTP 200 parses into ``RunResult[Any]``; any other status maps to the frozen
|
|
14
|
-
error hierarchy. Retries cover
|
|
15
|
-
timeouts
|
|
15
|
+
error hierarchy. Retries cover HTTP 429 and retry-safe transport failures,
|
|
16
|
+
never timeouts. Billed POSTs retry only connection-establishment failures that
|
|
17
|
+
prove no request was delivered. A ``ReadError`` is ambiguous: it can occur
|
|
18
|
+
before the server reads the request, as well as after a body was sent, so it
|
|
19
|
+
must not retry. Idempotent or bodyless requests retry any transport failure.
|
|
20
|
+
Backoff is jittered exponential and honors ``Retry-After`` on 429.
|
|
16
21
|
"""
|
|
17
22
|
|
|
18
23
|
from __future__ import annotations
|
|
@@ -33,6 +38,10 @@ from ._errors import (
|
|
|
33
38
|
TimeoutError,
|
|
34
39
|
error_for_status,
|
|
35
40
|
)
|
|
41
|
+
from ._idempotency import (
|
|
42
|
+
generate_idempotency_key,
|
|
43
|
+
validate_idempotency_key,
|
|
44
|
+
)
|
|
36
45
|
from .types import RequestOptions, RunResult
|
|
37
46
|
|
|
38
47
|
__all__ = [
|
|
@@ -42,6 +51,7 @@ __all__ = [
|
|
|
42
51
|
"compute_delay",
|
|
43
52
|
"RetryState",
|
|
44
53
|
"error_message",
|
|
54
|
+
"error_details",
|
|
45
55
|
"as_dict",
|
|
46
56
|
"is_retryable_error",
|
|
47
57
|
"sleep",
|
|
@@ -77,6 +87,7 @@ def build_request(
|
|
|
77
87
|
api_key: str,
|
|
78
88
|
options: RequestOptions | None,
|
|
79
89
|
timeout: float,
|
|
90
|
+
idempotency: str,
|
|
80
91
|
) -> httpx.Request:
|
|
81
92
|
"""Assemble the httpx.Request for a SKU run (no client bound).
|
|
82
93
|
|
|
@@ -89,6 +100,16 @@ def build_request(
|
|
|
89
100
|
"Content-Type": "application/json",
|
|
90
101
|
"Accept": "application/json",
|
|
91
102
|
}
|
|
103
|
+
if idempotency == "auto":
|
|
104
|
+
key = (
|
|
105
|
+
options["idempotency_key"]
|
|
106
|
+
if options is not None and "idempotency_key" in options
|
|
107
|
+
else generate_idempotency_key()
|
|
108
|
+
)
|
|
109
|
+
validate_idempotency_key(key)
|
|
110
|
+
headers["Idempotency-Key"] = key
|
|
111
|
+
# This request is built once before the retry loop. httpx serializes ``json``
|
|
112
|
+
# here, so every send reuses the exact raw body bytes used for fingerprinting.
|
|
92
113
|
return httpx.Request(
|
|
93
114
|
"POST",
|
|
94
115
|
url,
|
|
@@ -111,11 +132,19 @@ def as_dict(value: object) -> dict[str, object]:
|
|
|
111
132
|
|
|
112
133
|
|
|
113
134
|
def error_message(body: object, status: int) -> str:
|
|
114
|
-
"""Extract the ``{error}``
|
|
115
|
-
|
|
135
|
+
"""Extract the ``{error, code}`` details and return the message."""
|
|
136
|
+
return error_details(body, status)[0]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def error_details(body: object, status: int) -> tuple[str, str | None]:
|
|
140
|
+
"""Extract the error message and stable code from a JSON body."""
|
|
141
|
+
body_dict = as_dict(body)
|
|
142
|
+
err = body_dict.get("error")
|
|
143
|
+
code = body_dict.get("code")
|
|
144
|
+
parsed_code = code if isinstance(code, str) and code else None
|
|
116
145
|
if isinstance(err, str):
|
|
117
|
-
return err
|
|
118
|
-
return _fallback_message(status)
|
|
146
|
+
return err, parsed_code
|
|
147
|
+
return _fallback_message(status), parsed_code
|
|
119
148
|
|
|
120
149
|
|
|
121
150
|
def parse_raw(response: httpx.Response) -> dict[str, Any]:
|
|
@@ -149,9 +178,9 @@ def parse_raw(response: httpx.Response) -> dict[str, Any]:
|
|
|
149
178
|
err_body = response.json()
|
|
150
179
|
except ValueError:
|
|
151
180
|
err_body = None
|
|
152
|
-
message =
|
|
181
|
+
message, code = error_details(err_body, response.status_code)
|
|
153
182
|
raise error_for_status(
|
|
154
|
-
response.status_code, message, request_id=request_id
|
|
183
|
+
response.status_code, message, request_id=request_id, code=code
|
|
155
184
|
)
|
|
156
185
|
|
|
157
186
|
|
|
@@ -227,11 +256,34 @@ class RetryState:
|
|
|
227
256
|
return delay
|
|
228
257
|
|
|
229
258
|
|
|
230
|
-
def is_retryable_error(
|
|
231
|
-
|
|
232
|
-
|
|
259
|
+
def is_retryable_error(
|
|
260
|
+
exc: AnyAPIError | httpx.HTTPError,
|
|
261
|
+
*,
|
|
262
|
+
request_may_be_billed: bool = False,
|
|
263
|
+
) -> bool:
|
|
264
|
+
"""Retry 429s and transport failures that are safe for this request shape.
|
|
265
|
+
|
|
266
|
+
Idempotent or bodyless requests retry any non-timeout transport failure.
|
|
267
|
+
A billed request retries only ``httpx.ConnectError``, the reliable pre-send
|
|
268
|
+
signal. ``httpx.ReadError`` does not establish that bytes were delivered:
|
|
269
|
+
an accept-then-close race or a stale keepalive connection can raise it
|
|
270
|
+
before the server reads the request. It is still unsafe to retry because
|
|
271
|
+
the same type can also represent a failure after delivery. The default
|
|
272
|
+
preserves the one-argument behavior for callers passing an ``AnyAPIError``.
|
|
273
|
+
"""
|
|
274
|
+
# ConnectTimeout is a TimeoutException, not a ConnectError. The billed POST
|
|
275
|
+
# loops catch all TimeoutException instances before calling this predicate,
|
|
276
|
+
# so the httpx term is unreachable there. Retain it so direct callers stay
|
|
277
|
+
# timeout-safe and the predicate does not depend on caller catch ordering.
|
|
278
|
+
if isinstance(exc, (TimeoutError, httpx.TimeoutException)):
|
|
233
279
|
return False
|
|
234
|
-
|
|
280
|
+
if isinstance(exc, RateLimitedError):
|
|
281
|
+
return True
|
|
282
|
+
if isinstance(exc, ConnectionError):
|
|
283
|
+
return not request_may_be_billed
|
|
284
|
+
if isinstance(exc, httpx.HTTPError):
|
|
285
|
+
return not request_may_be_billed or isinstance(exc, httpx.ConnectError)
|
|
286
|
+
return False
|
|
235
287
|
|
|
236
288
|
|
|
237
289
|
def sleep(seconds: float) -> None:
|
|
@@ -155,7 +155,7 @@ class BlueskyNamespace:
|
|
|
155
155
|
"""Bluesky User Posts
|
|
156
156
|
|
|
157
157
|
List a Bluesky account's recent posts (text, author handle, like, reply, and
|
|
158
|
-
repost counts) by handle as clean JSON
|
|
158
|
+
repost counts) by handle as clean JSON.
|
|
159
159
|
|
|
160
160
|
Price: $0.002 per request.
|
|
161
161
|
|
|
@@ -225,7 +225,7 @@ class AsyncBlueskyNamespace:
|
|
|
225
225
|
"""Bluesky User Posts
|
|
226
226
|
|
|
227
227
|
List a Bluesky account's recent posts (text, author handle, like, reply, and
|
|
228
|
-
repost counts) by handle as clean JSON
|
|
228
|
+
repost counts) by handle as clean JSON.
|
|
229
229
|
|
|
230
230
|
Price: $0.002 per request.
|
|
231
231
|
|
|
@@ -26,7 +26,7 @@ class EmailVerifyInput(TypedDict, total=False):
|
|
|
26
26
|
"""Input for Email Verifier."""
|
|
27
27
|
|
|
28
28
|
email: Required[str]
|
|
29
|
-
"""The email address to verify (e.g. jane.doe@acme.com)."""
|
|
29
|
+
"""The email address to verify (e.g. jane.doe@acme.com). Exactly one @, a dotted domain, no whitespace or angle brackets. Addresses on reserved, never-deliverable TLDs (.invalid, .test, .example, .localhost, .local, .internal, .blink) and HTML/JSON escape artifacts (a u003e prefix) are rejected locally with no charge."""
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class EmailFindData(BaseModel):
|
|
@@ -52,7 +52,7 @@ class EmailFindItem(BaseModel):
|
|
|
52
52
|
|
|
53
53
|
class EmailVerifyData(BaseModel):
|
|
54
54
|
items: list[EmailVerifyItem] = Field(
|
|
55
|
-
description="Verification records: the email address with its deliverability verdict and
|
|
55
|
+
description="Verification records: the email address with its deliverability verdict and the domain, mailbox, and reputation signals behind it. A record is returned for every syntactically valid address, including ones the verdict marks undeliverable. Populated whenever the provider has data for the entity."
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
|
|
@@ -109,8 +109,11 @@ class EmailNamespace:
|
|
|
109
109
|
) -> RunResult[EmailVerifyData]:
|
|
110
110
|
"""Email Verifier
|
|
111
111
|
|
|
112
|
-
Verify
|
|
113
|
-
|
|
112
|
+
Verify an email address for deliverability: a status verdict (valid, risky,
|
|
113
|
+
or invalid) with domain, mailbox, catch-all, disposable, and role signals
|
|
114
|
+
plus a confidence score. Malformed addresses are rejected by the input
|
|
115
|
+
schema with no charge; every syntactically valid address returns a billed
|
|
116
|
+
verdict, including undeliverable ones.
|
|
114
117
|
|
|
115
118
|
Price: $0 per request plus $0.0008 per result (maximum $0.0008).
|
|
116
119
|
|
|
@@ -154,8 +157,11 @@ class AsyncEmailNamespace:
|
|
|
154
157
|
) -> RunResult[EmailVerifyData]:
|
|
155
158
|
"""Email Verifier
|
|
156
159
|
|
|
157
|
-
Verify
|
|
158
|
-
|
|
160
|
+
Verify an email address for deliverability: a status verdict (valid, risky,
|
|
161
|
+
or invalid) with domain, mailbox, catch-all, disposable, and role signals
|
|
162
|
+
plus a confidence score. Malformed addresses are rejected by the input
|
|
163
|
+
schema with no charge; every syntactically valid address returns a billed
|
|
164
|
+
verdict, including undeliverable ones.
|
|
159
165
|
|
|
160
166
|
Price: $0 per request plus $0.0008 per result (maximum $0.0008).
|
|
161
167
|
|