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.
Files changed (84) hide show
  1. {getanyapi-0.9.2 → getanyapi-0.9.6}/PKG-INFO +15 -5
  2. {getanyapi-0.9.2 → getanyapi-0.9.6}/README.md +14 -4
  3. {getanyapi-0.9.2 → getanyapi-0.9.6}/pyproject.toml +1 -1
  4. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/__init__.py +1 -1
  5. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_account.py +5 -2
  6. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_async_client.py +34 -4
  7. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_client.py +34 -4
  8. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_errors.py +14 -3
  9. getanyapi-0.9.6/src/getanyapi/_idempotency.py +34 -0
  10. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_pagination.py +26 -2
  11. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/_transport.py +64 -12
  12. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/bluesky.py +2 -2
  13. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/email.py +12 -6
  14. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/facebook.py +33 -35
  15. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/github.py +10 -20
  16. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/instagram.py +38 -52
  17. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/linkedin.py +4 -6
  18. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/reddit.py +12 -20
  19. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tiktok.py +40 -60
  20. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tiktok_shop.py +6 -10
  21. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/twitter.py +10 -20
  22. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/youtube.py +32 -42
  23. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/types.py +2 -0
  24. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_errors.py +16 -0
  25. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_pagination.py +40 -0
  26. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_transport.py +171 -5
  27. {getanyapi-0.9.2 → getanyapi-0.9.6}/.gitignore +0 -0
  28. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/__init__.py +0 -0
  29. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/ahrefs.py +0 -0
  30. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/airbnb.py +0 -0
  31. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/alibaba.py +0 -0
  32. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/amazon.py +0 -0
  33. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/apollo.py +0 -0
  34. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/appstore.py +0 -0
  35. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/booking.py +0 -0
  36. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/coinmarketcap.py +0 -0
  37. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/congress.py +0 -0
  38. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/dexscreener.py +0 -0
  39. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/douyin.py +0 -0
  40. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/ebay.py +0 -0
  41. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/fiverr.py +0 -0
  42. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/glassdoor.py +0 -0
  43. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google.py +0 -0
  44. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_ads.py +0 -0
  45. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_finance.py +0 -0
  46. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/google_shopping.py +0 -0
  47. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/hackernews.py +0 -0
  48. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/indeed.py +0 -0
  49. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/maps.py +0 -0
  50. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/pandaexpress.py +0 -0
  51. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/person.py +0 -0
  52. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/pinterest.py +0 -0
  53. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/playstore.py +0 -0
  54. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/polymarket.py +0 -0
  55. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/realtor.py +0 -0
  56. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/redfin.py +0 -0
  57. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/rednote.py +0 -0
  58. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/sec.py +0 -0
  59. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/semrush.py +0 -0
  60. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/seo.py +0 -0
  61. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/snapchat.py +0 -0
  62. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/social.py +0 -0
  63. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/spotify.py +0 -0
  64. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/substack.py +0 -0
  65. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/threads.py +0 -0
  66. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/tripadvisor.py +0 -0
  67. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/trustpilot.py +0 -0
  68. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/truthsocial.py +0 -0
  69. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/upwork.py +0 -0
  70. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/walmart.py +0 -0
  71. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/web.py +0 -0
  72. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/weibo.py +0 -0
  73. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/whatsapp.py +0 -0
  74. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/yahoo_finance.py +0 -0
  75. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/yelp.py +0 -0
  76. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/zhihu.py +0 -0
  77. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/platforms/zillow.py +0 -0
  78. {getanyapi-0.9.2 → getanyapi-0.9.6}/src/getanyapi/py.typed +0 -0
  79. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/conftest.py +0 -0
  80. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_account.py +0 -0
  81. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_client.py +0 -0
  82. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_envelope.py +0 -0
  83. {getanyapi-0.9.2 → getanyapi-0.9.6}/tests/test_fixture_roundtrip.py +0 -0
  84. {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.2
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 (retried) |
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 `Retry-After`. Default `max_retries` is 2
146
- (up to 3 attempts); set it on the client (`AnyAPI(max_retries=...)`) or per request via
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 (retried) |
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 `Retry-After`. Default `max_retries` is 2
117
- (up to 3 attempts); set it on the client (`AnyAPI(max_retries=...)`) or per request via
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "getanyapi"
7
- version = "0.9.2"
7
+ version = "0.9.6"
8
8
  description = "Official typed Python SDK for AnyAPI: any API, one wallet, USD, no subscriptions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -49,7 +49,7 @@ from .types import (
49
49
  unwrap,
50
50
  )
51
51
 
52
- __version__ = "0.9.2"
52
+ __version__ = "0.9.6"
53
53
 
54
54
  __all__ = [
55
55
  # clients + top-level functions
@@ -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, error_message
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
- return error_for_status(status, error_message(body, status), request_id=request_id)
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 retry.can_retry:
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
- response = await self._http.get(url, params=params or {}, headers=headers)
169
- return self._json_or_raise(response)
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 retry.can_retry:
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
- response = self._http.get(url, params=params or {}, headers=headers)
204
- return self._json_or_raise(response)
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, message: str, *, status: int, request_id: str | None = None
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, message: str, *, request_id: str | None = None
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("RequestOptions | None", self._page_options),
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("RequestOptions | None", self._page_options),
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 only HTTP 429 and network failures, never
15
- timeouts, with jittered exponential backoff honoring ``Retry-After`` on 429.
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}`` string from a JSON body, else a generic message."""
115
- err = as_dict(body).get("error")
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 = error_message(err_body, response.status_code)
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(exc: AnyAPIError) -> bool:
231
- """Retry only rate limits and connection failures, never timeouts."""
232
- if isinstance(exc, TimeoutError):
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
- return isinstance(exc, (RateLimitedError, ConnectionError))
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, normalized across providers.
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, normalized across providers.
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 syntax, domain, and mailbox check details. Populated whenever the provider has data for the entity."
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 any email address for deliverability: syntax, domain, and mailbox
113
- checks in one normalized response.
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 any email address for deliverability: syntax, domain, and mailbox
158
- checks in one normalized response.
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