getanyapi 0.9.2__tar.gz → 0.9.5__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.5}/PKG-INFO +15 -5
- {getanyapi-0.9.2 → getanyapi-0.9.5}/README.md +14 -4
- {getanyapi-0.9.2 → getanyapi-0.9.5}/pyproject.toml +1 -1
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/__init__.py +1 -1
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_account.py +5 -2
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_async_client.py +34 -4
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_client.py +34 -4
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_errors.py +14 -3
- getanyapi-0.9.5/src/getanyapi/_idempotency.py +34 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_pagination.py +26 -2
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/_transport.py +64 -12
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/email.py +12 -6
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/facebook.py +1 -1
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/types.py +2 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_errors.py +16 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_pagination.py +40 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_transport.py +171 -5
- {getanyapi-0.9.2 → getanyapi-0.9.5}/.gitignore +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/__init__.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/ahrefs.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/airbnb.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/alibaba.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/amazon.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/apollo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/appstore.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/bluesky.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/booking.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/coinmarketcap.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/congress.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/dexscreener.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/douyin.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/ebay.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/fiverr.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/github.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/glassdoor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/google.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/google_ads.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/google_finance.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/google_shopping.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/hackernews.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/indeed.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/instagram.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/linkedin.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/maps.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/pandaexpress.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/person.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/pinterest.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/playstore.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/polymarket.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/realtor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/reddit.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/redfin.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/rednote.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/sec.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/semrush.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/seo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/snapchat.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/social.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/spotify.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/substack.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/threads.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/tiktok.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/tiktok_shop.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/tripadvisor.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/trustpilot.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/truthsocial.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/twitter.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/upwork.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/walmart.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/web.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/weibo.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/whatsapp.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/yahoo_finance.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/yelp.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/youtube.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/zhihu.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/platforms/zillow.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/src/getanyapi/py.typed +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/conftest.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_account.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_client.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_envelope.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/tests/test_fixture_roundtrip.py +0 -0
- {getanyapi-0.9.2 → getanyapi-0.9.5}/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.5
|
|
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:
|
|
@@ -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
|
|
|
@@ -81,7 +81,7 @@ class FacebookCompanyAdsInput(TypedDict, total=False):
|
|
|
81
81
|
"""Input for Facebook Company Ads."""
|
|
82
82
|
|
|
83
83
|
companyName: NotRequired[str]
|
|
84
|
-
"""Company name to search (e.g. "nike").
|
|
84
|
+
"""Company name to search (e.g. "nike"). Exact-match and case-sensitive against the Meta Ad Library index; an advertiser with no indexed page returns found:false."""
|
|
85
85
|
country: NotRequired[str]
|
|
86
86
|
"""Two-letter country code to scope results. Defaults to all countries."""
|
|
87
87
|
cursor: NotRequired[str]
|
|
@@ -255,6 +255,7 @@ class RequestOptions(TypedDict, total=False):
|
|
|
255
255
|
``fields``, ``max_items``, and ``summary`` shape the response and do NOT
|
|
256
256
|
change cost. ``timeout`` overrides the client per-request timeout (seconds).
|
|
257
257
|
``max_retries`` overrides the client retry cap for this call.
|
|
258
|
+
``idempotency_key`` overrides the generated key for this billed POST.
|
|
258
259
|
"""
|
|
259
260
|
|
|
260
261
|
fields: list[str]
|
|
@@ -262,6 +263,7 @@ class RequestOptions(TypedDict, total=False):
|
|
|
262
263
|
summary: bool
|
|
263
264
|
timeout: float
|
|
264
265
|
max_retries: int
|
|
266
|
+
idempotency_key: str
|
|
265
267
|
|
|
266
268
|
|
|
267
269
|
class AgentSignupResult(BaseModel):
|
|
@@ -58,3 +58,19 @@ def test_unparseable_body_uses_fallback_message() -> None:
|
|
|
58
58
|
client.run("amazon.reviews", {})
|
|
59
59
|
assert "status 400" in str(exc.value)
|
|
60
60
|
assert exc.value.request_id is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_error_body_code_is_exposed() -> None:
|
|
64
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
65
|
+
return json_response(
|
|
66
|
+
409,
|
|
67
|
+
{
|
|
68
|
+
"error": "the key is already running",
|
|
69
|
+
"code": "idempotency_in_progress",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
client, _ = make_sync_client(respond, max_retries=0)
|
|
74
|
+
with pytest.raises(AnyAPIError) as exc:
|
|
75
|
+
client.run("amazon.reviews", {"product": "B0"})
|
|
76
|
+
assert exc.value.code == "idempotency_in_progress"
|
|
@@ -59,6 +59,28 @@ def test_sync_pages_yields_whole_run_results() -> None:
|
|
|
59
59
|
assert pages[0].output.data["ads"][0]["id"] == 1
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def test_sync_derives_distinct_bounded_idempotency_key_per_page() -> None:
|
|
63
|
+
client, rec = make_sync_client(_multi_page_responder())
|
|
64
|
+
base_key = "k" * 255
|
|
65
|
+
pages = list(
|
|
66
|
+
paginate(
|
|
67
|
+
client,
|
|
68
|
+
"facebook.ads_search",
|
|
69
|
+
{"q": "x"},
|
|
70
|
+
"ads",
|
|
71
|
+
options={"idempotency_key": base_key},
|
|
72
|
+
).pages()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert len(pages) == 3
|
|
76
|
+
keys = [request.headers["idempotency-key"] for request in rec.requests]
|
|
77
|
+
assert [len(key) for key in keys] == [255, 255, 255]
|
|
78
|
+
assert keys[0].endswith("-p1")
|
|
79
|
+
assert keys[1].endswith("-p2")
|
|
80
|
+
assert keys[2].endswith("-p3")
|
|
81
|
+
assert len(set(keys)) == 3
|
|
82
|
+
|
|
83
|
+
|
|
62
84
|
def test_sync_stops_on_not_found() -> None:
|
|
63
85
|
def respond(_req: httpx.Request) -> httpx.Response:
|
|
64
86
|
return json_response(200, run_envelope(None, found=False))
|
|
@@ -188,3 +210,21 @@ async def test_async_pages_and_max_items() -> None:
|
|
|
188
210
|
assert len(pages) == 3
|
|
189
211
|
await client.aclose()
|
|
190
212
|
await client2.aclose()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def test_async_derives_distinct_idempotency_key_per_page() -> None:
|
|
216
|
+
client, rec = make_async_client(_multi_page_responder())
|
|
217
|
+
paginator = apaginate(
|
|
218
|
+
client,
|
|
219
|
+
"facebook.ads_search",
|
|
220
|
+
{"q": "x"},
|
|
221
|
+
"ads",
|
|
222
|
+
options={"idempotency_key": "customer-key"},
|
|
223
|
+
)
|
|
224
|
+
pages = [page async for page in paginator.pages()]
|
|
225
|
+
|
|
226
|
+
assert len(pages) == 3
|
|
227
|
+
assert [
|
|
228
|
+
request.headers["idempotency-key"] for request in rec.requests
|
|
229
|
+
] == ["customer-key-p1", "customer-key-p2", "customer-key-p3"]
|
|
230
|
+
await client.aclose()
|
|
@@ -57,6 +57,64 @@ def test_no_query_params_when_unset() -> None:
|
|
|
57
57
|
assert str(rec.last.url.params) == ""
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_idempotency_key_and_body_bytes_are_stable_across_retries(
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
) -> None:
|
|
63
|
+
import getanyapi._transport as transport
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr(transport, "sleep", lambda _s: None)
|
|
66
|
+
calls = {"n": 0}
|
|
67
|
+
|
|
68
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
69
|
+
calls["n"] += 1
|
|
70
|
+
if calls["n"] == 1:
|
|
71
|
+
return json_response(429, {"error": "slow down"})
|
|
72
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
73
|
+
|
|
74
|
+
client, rec = make_sync_client(respond, max_retries=1)
|
|
75
|
+
client.run("x.y", {"b": 2, "a": 1})
|
|
76
|
+
|
|
77
|
+
assert len(rec.requests) == 2
|
|
78
|
+
assert rec.requests[0] is rec.requests[1]
|
|
79
|
+
assert rec.requests[0].headers["idempotency-key"] == rec.requests[1].headers[
|
|
80
|
+
"idempotency-key"
|
|
81
|
+
]
|
|
82
|
+
assert rec.requests[0].content == rec.requests[1].content
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_fresh_idempotency_key_for_each_new_call() -> None:
|
|
86
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
87
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
88
|
+
|
|
89
|
+
client, rec = make_sync_client(respond)
|
|
90
|
+
client.run("x.y", {})
|
|
91
|
+
client.run("x.y", {})
|
|
92
|
+
|
|
93
|
+
assert rec.requests[0].headers["idempotency-key"] != rec.requests[1].headers[
|
|
94
|
+
"idempotency-key"
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_per_request_idempotency_key_override() -> None:
|
|
99
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
100
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
101
|
+
|
|
102
|
+
client, rec = make_sync_client(respond)
|
|
103
|
+
client.run("x.y", {}, options={"idempotency_key": "customer-key"})
|
|
104
|
+
|
|
105
|
+
assert rec.last.headers["idempotency-key"] == "customer-key"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_idempotency_kill_switch_omits_header() -> None:
|
|
109
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
110
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
111
|
+
|
|
112
|
+
client, rec = make_sync_client(respond, idempotency="off")
|
|
113
|
+
client.run("x.y", {}, options={"idempotency_key": "customer-key"})
|
|
114
|
+
|
|
115
|
+
assert "idempotency-key" not in rec.last.headers
|
|
116
|
+
|
|
117
|
+
|
|
60
118
|
def test_retry_on_429_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
61
119
|
import getanyapi._transport as transport
|
|
62
120
|
|
|
@@ -163,19 +221,63 @@ def test_timeout_not_retried() -> None:
|
|
|
163
221
|
assert len(rec.requests) == 1 # timeouts never retried
|
|
164
222
|
|
|
165
223
|
|
|
166
|
-
def
|
|
224
|
+
def test_pre_send_connect_error_retried(
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
) -> None:
|
|
167
227
|
import getanyapi._transport as transport
|
|
168
228
|
|
|
169
229
|
monkeypatch.setattr(transport, "sleep", lambda _s: None)
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
|
|
231
|
+
calls = {"n": 0}
|
|
232
|
+
|
|
233
|
+
def respond(req: httpx.Request) -> httpx.Response:
|
|
234
|
+
calls["n"] += 1
|
|
235
|
+
if calls["n"] == 1:
|
|
236
|
+
raise httpx.ConnectError("refused", request=req)
|
|
237
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
238
|
+
|
|
239
|
+
client, rec = make_sync_client(respond, max_retries=2)
|
|
240
|
+
result = client.run("x.y", {})
|
|
241
|
+
assert result.output.found is True
|
|
242
|
+
assert len(rec.requests) == 2
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_idempotent_get_retries_post_send_read_error(
|
|
246
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
247
|
+
) -> None:
|
|
248
|
+
import getanyapi._transport as transport
|
|
249
|
+
|
|
250
|
+
monkeypatch.setattr(transport, "sleep", lambda _s: None)
|
|
251
|
+
calls = {"n": 0}
|
|
252
|
+
|
|
253
|
+
def respond(req: httpx.Request) -> httpx.Response:
|
|
254
|
+
calls["n"] += 1
|
|
255
|
+
if calls["n"] == 1:
|
|
256
|
+
raise httpx.ReadError("response reset", request=req)
|
|
257
|
+
return json_response(200, {"usd": 1.25})
|
|
258
|
+
|
|
259
|
+
client, rec = make_sync_client(respond, max_retries=1)
|
|
260
|
+
result = client.balance()
|
|
261
|
+
assert result.usd == 1.25
|
|
262
|
+
assert len(rec.requests) == 2
|
|
263
|
+
assert "idempotency-key" not in rec.requests[0].headers
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_billed_post_does_not_retry_post_send_read_error(
|
|
267
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
268
|
+
) -> None:
|
|
269
|
+
import getanyapi._transport as transport
|
|
270
|
+
|
|
271
|
+
monkeypatch.setattr(transport, "sleep", lambda _s: None)
|
|
272
|
+
|
|
273
|
+
def respond(req: httpx.Request) -> httpx.Response:
|
|
274
|
+
raise httpx.ReadError("response reset", request=req)
|
|
173
275
|
|
|
174
276
|
client, rec = make_sync_client(respond, max_retries=2)
|
|
175
277
|
with pytest.raises(ConnectionError) as exc:
|
|
176
|
-
client.run("x.y", {})
|
|
278
|
+
client.run("x.y", {"query": "sent"})
|
|
177
279
|
assert exc.value.status == 0
|
|
178
|
-
assert len(rec.requests) ==
|
|
280
|
+
assert len(rec.requests) == 1
|
|
179
281
|
|
|
180
282
|
|
|
181
283
|
def test_per_request_max_retries_override(
|
|
@@ -220,6 +322,35 @@ async def test_async_retry_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
220
322
|
await client.aclose()
|
|
221
323
|
|
|
222
324
|
|
|
325
|
+
async def test_async_idempotency_key_reused_across_retries(
|
|
326
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
327
|
+
) -> None:
|
|
328
|
+
import asyncio
|
|
329
|
+
|
|
330
|
+
async def fake_sleep(_s: float) -> None:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
|
334
|
+
calls = {"n": 0}
|
|
335
|
+
|
|
336
|
+
def respond(_req: httpx.Request) -> httpx.Response:
|
|
337
|
+
calls["n"] += 1
|
|
338
|
+
if calls["n"] == 1:
|
|
339
|
+
return json_response(429, {"error": "slow"})
|
|
340
|
+
return json_response(200, run_envelope({"ok": True}))
|
|
341
|
+
|
|
342
|
+
client, rec = make_async_client(respond, max_retries=1)
|
|
343
|
+
await client.run("x.y", {"query": "stable"})
|
|
344
|
+
|
|
345
|
+
assert len(rec.requests) == 2
|
|
346
|
+
assert rec.requests[0] is rec.requests[1]
|
|
347
|
+
assert rec.requests[0].headers["idempotency-key"] == rec.requests[1].headers[
|
|
348
|
+
"idempotency-key"
|
|
349
|
+
]
|
|
350
|
+
assert rec.requests[0].content == rec.requests[1].content
|
|
351
|
+
await client.aclose()
|
|
352
|
+
|
|
353
|
+
|
|
223
354
|
async def test_async_timeout_not_retried() -> None:
|
|
224
355
|
def respond(_req: httpx.Request) -> httpx.Response:
|
|
225
356
|
raise httpx.ReadTimeout("timed out")
|
|
@@ -229,3 +360,38 @@ async def test_async_timeout_not_retried() -> None:
|
|
|
229
360
|
await client.run("x.y", {})
|
|
230
361
|
assert len(rec.requests) == 1
|
|
231
362
|
await client.aclose()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def test_async_idempotent_get_retries_post_send_read_error(
|
|
366
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
367
|
+
) -> None:
|
|
368
|
+
import asyncio
|
|
369
|
+
|
|
370
|
+
async def fake_sleep(_s: float) -> None:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
|
374
|
+
calls = {"n": 0}
|
|
375
|
+
|
|
376
|
+
def respond(req: httpx.Request) -> httpx.Response:
|
|
377
|
+
calls["n"] += 1
|
|
378
|
+
if calls["n"] == 1:
|
|
379
|
+
raise httpx.ReadError("response reset", request=req)
|
|
380
|
+
return json_response(200, {"usd": 1.25})
|
|
381
|
+
|
|
382
|
+
client, rec = make_async_client(respond, max_retries=1)
|
|
383
|
+
result = await client.balance()
|
|
384
|
+
assert result.usd == 1.25
|
|
385
|
+
assert len(rec.requests) == 2
|
|
386
|
+
await client.aclose()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def test_async_billed_post_does_not_retry_post_send_read_error() -> None:
|
|
390
|
+
def respond(req: httpx.Request) -> httpx.Response:
|
|
391
|
+
raise httpx.ReadError("response reset", request=req)
|
|
392
|
+
|
|
393
|
+
client, rec = make_async_client(respond, max_retries=2)
|
|
394
|
+
with pytest.raises(ConnectionError):
|
|
395
|
+
await client.run("x.y", {"query": "sent"})
|
|
396
|
+
assert len(rec.requests) == 1
|
|
397
|
+
await client.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|