arex-python-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arex/__init__.py ADDED
@@ -0,0 +1,137 @@
1
+ """Python SDK for the AREX public API: search, browse, research, usage."""
2
+
3
+ import importlib.metadata
4
+
5
+ from arex.async_client import AsyncArex
6
+ from arex.client import Arex
7
+ from arex.errors import (
8
+ ArexApiError,
9
+ ArexError,
10
+ ArexNetworkError,
11
+ ArexTimeoutError,
12
+ AuthenticationError,
13
+ CreditsExhaustedError,
14
+ InvalidRequestError,
15
+ NotFoundError,
16
+ PermissionDeniedError,
17
+ RateLimitError,
18
+ ResearchRunCancelledError,
19
+ ResearchRunFailedError,
20
+ ServiceUnavailableError,
21
+ SourceNotAvailableError,
22
+ api_error_from_response,
23
+ )
24
+ from arex.models import (
25
+ API_KEY_PREFIX,
26
+ BROWSE_AUTO_TIMEOUT_MS,
27
+ BROWSE_FLEX_TIMEOUT_MS,
28
+ BROWSE_TIERS,
29
+ DEEP_POLL_TIMEOUT_MS,
30
+ DEFAULT_BASE_URL,
31
+ DEFAULT_BROWSE_MAX_CHARACTERS,
32
+ DEFAULT_BROWSE_TIER,
33
+ DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS,
34
+ DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS,
35
+ DEFAULT_MAX_RETRIES,
36
+ DEFAULT_POLL_INTERVAL_MS,
37
+ DEFAULT_RESEARCH_MODE,
38
+ DEFAULT_RETRY_DELAY_MS,
39
+ DEFAULT_SEARCH_LIMIT,
40
+ FAST_POLL_TIMEOUT_MS,
41
+ LIVECRAWL_TIMEOUT_MARGIN_MS,
42
+ RESEARCH_MODES,
43
+ RESEARCH_REQUEST_TIMEOUT_MS,
44
+ SEARCH_TIMEOUT_MS,
45
+ USAGE_TIMEOUT_MS,
46
+ BrowseDocument,
47
+ BrowseTier,
48
+ Credits,
49
+ CreditUsage,
50
+ CreditWeek,
51
+ CreditWindow,
52
+ ResearchFailureCode,
53
+ ResearchMode,
54
+ ResearchModeRequest,
55
+ ResearchResult,
56
+ ResearchRun,
57
+ ResearchRunError,
58
+ ResearchSource,
59
+ ResearchStatus,
60
+ SearchResponse,
61
+ SearchResult,
62
+ is_arex_api_key,
63
+ )
64
+ from arex.research import AsyncResearch, Research
65
+
66
+
67
+ def __getattr__(name: str) -> str:
68
+ # Read lazily from the installed distribution so the version has a single
69
+ # source (pyproject.toml); an uninstalled tree raises rather than guessing.
70
+ if name == "__version__":
71
+ return importlib.metadata.version("arex-python-sdk")
72
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
73
+
74
+
75
+ __all__ = [
76
+ "API_KEY_PREFIX",
77
+ "BROWSE_AUTO_TIMEOUT_MS",
78
+ "BROWSE_FLEX_TIMEOUT_MS",
79
+ "BROWSE_TIERS",
80
+ "DEEP_POLL_TIMEOUT_MS",
81
+ "DEFAULT_BASE_URL",
82
+ "DEFAULT_BROWSE_MAX_CHARACTERS",
83
+ "DEFAULT_BROWSE_TIER",
84
+ "DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS",
85
+ "DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS",
86
+ "DEFAULT_MAX_RETRIES",
87
+ "DEFAULT_POLL_INTERVAL_MS",
88
+ "DEFAULT_RESEARCH_MODE",
89
+ "DEFAULT_RETRY_DELAY_MS",
90
+ "DEFAULT_SEARCH_LIMIT",
91
+ "FAST_POLL_TIMEOUT_MS",
92
+ "LIVECRAWL_TIMEOUT_MARGIN_MS",
93
+ "RESEARCH_MODES",
94
+ "RESEARCH_REQUEST_TIMEOUT_MS",
95
+ "SEARCH_TIMEOUT_MS",
96
+ "USAGE_TIMEOUT_MS",
97
+ "Arex",
98
+ "ArexApiError",
99
+ # errors
100
+ "ArexError",
101
+ "ArexNetworkError",
102
+ "ArexTimeoutError",
103
+ "AsyncArex",
104
+ "AsyncResearch",
105
+ "AuthenticationError",
106
+ "BrowseDocument",
107
+ # models
108
+ "BrowseTier",
109
+ "CreditUsage",
110
+ "CreditWeek",
111
+ "CreditWindow",
112
+ "Credits",
113
+ "CreditsExhaustedError",
114
+ "InvalidRequestError",
115
+ "NotFoundError",
116
+ "PermissionDeniedError",
117
+ "RateLimitError",
118
+ "Research",
119
+ "ResearchFailureCode",
120
+ "ResearchMode",
121
+ "ResearchModeRequest",
122
+ "ResearchResult",
123
+ "ResearchRun",
124
+ "ResearchRunCancelledError",
125
+ "ResearchRunError",
126
+ "ResearchRunFailedError",
127
+ "ResearchSource",
128
+ "ResearchStatus",
129
+ "SearchResponse",
130
+ "SearchResult",
131
+ "ServiceUnavailableError",
132
+ "SourceNotAvailableError",
133
+ "__version__",
134
+ "api_error_from_response",
135
+ # contract constants
136
+ "is_arex_api_key",
137
+ ]
arex/_poll.py ADDED
@@ -0,0 +1,126 @@
1
+ """Fixed-interval polling with a deadline.
2
+
3
+ `get` receives the milliseconds left before the deadline and returns
4
+ `(value, retry_after_ms)`: a non-`None` value ends the loop, and the
5
+ `Retry-After` of the most recent response overrides the fixed interval.
6
+ Inputs are validated by the resource before any charged call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import math
13
+ import time
14
+ from collections.abc import Awaitable, Callable
15
+ from typing import TypeVar
16
+
17
+ from arex.errors import ArexError, ArexTimeoutError
18
+
19
+ __all__ = ["async_poll_until", "poll_until"]
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ def _delay_seconds(retry_after_ms: int | None, poll_interval_ms: int) -> float:
25
+ return (retry_after_ms if retry_after_ms is not None else poll_interval_ms) / 1000
26
+
27
+
28
+ def _remaining_ms(deadline: float) -> int:
29
+ return math.ceil((deadline - time.monotonic()) * 1000)
30
+
31
+
32
+ def _poll_timeout(
33
+ exc: ArexError,
34
+ *,
35
+ deadline: float,
36
+ remaining_ms: int,
37
+ timeout_ms: int,
38
+ run_id: str,
39
+ ) -> ArexTimeoutError | None:
40
+ """Only elapsed time or a request capped by the poll budget is a poll timeout.
41
+
42
+ Retry eligibility belongs to the transport. A server Retry-After header
43
+ must not relabel a non-retried API error before the poll deadline.
44
+ """
45
+ now = time.monotonic()
46
+ cut = (
47
+ isinstance(exc, ArexTimeoutError)
48
+ and exc.phase == "request"
49
+ and exc.timeout_ms >= remaining_ms
50
+ )
51
+ if cut or now >= deadline:
52
+ return ArexTimeoutError("poll", timeout_ms, run_id)
53
+ return None
54
+
55
+
56
+ def poll_until(
57
+ get: Callable[[int], tuple[T | None, int | None]],
58
+ *,
59
+ poll_interval_ms: int,
60
+ timeout_ms: int,
61
+ run_id: str,
62
+ ) -> T:
63
+ """Call `get` until it yields a value, or raise `ArexTimeoutError`.
64
+
65
+ An outcome that arrives after the deadline is a poll timeout, whatever it is.
66
+ """
67
+ deadline = time.monotonic() + timeout_ms / 1000
68
+ while True:
69
+ remaining_ms = _remaining_ms(deadline)
70
+ if remaining_ms <= 0:
71
+ raise ArexTimeoutError("poll", timeout_ms, run_id)
72
+ try:
73
+ value, retry_after_ms = get(remaining_ms)
74
+ except ArexError as exc:
75
+ timeout = _poll_timeout(
76
+ exc,
77
+ deadline=deadline,
78
+ remaining_ms=remaining_ms,
79
+ timeout_ms=timeout_ms,
80
+ run_id=run_id,
81
+ )
82
+ if timeout is None:
83
+ raise
84
+ raise timeout from exc
85
+ remaining = deadline - time.monotonic()
86
+ if remaining <= 0:
87
+ raise ArexTimeoutError("poll", timeout_ms, run_id)
88
+ if value is not None:
89
+ return value
90
+ time.sleep(min(_delay_seconds(retry_after_ms, poll_interval_ms), remaining))
91
+
92
+
93
+ async def async_poll_until(
94
+ get: Callable[[int], Awaitable[tuple[T | None, int | None]]],
95
+ *,
96
+ poll_interval_ms: int,
97
+ timeout_ms: int,
98
+ run_id: str,
99
+ ) -> T:
100
+ """Await `get` until it yields a value, or raise `ArexTimeoutError`."""
101
+ deadline = time.monotonic() + timeout_ms / 1000
102
+ while True:
103
+ remaining_ms = _remaining_ms(deadline)
104
+ if remaining_ms <= 0:
105
+ raise ArexTimeoutError("poll", timeout_ms, run_id)
106
+ try:
107
+ value, retry_after_ms = await get(remaining_ms)
108
+ except ArexError as exc:
109
+ timeout = _poll_timeout(
110
+ exc,
111
+ deadline=deadline,
112
+ remaining_ms=remaining_ms,
113
+ timeout_ms=timeout_ms,
114
+ run_id=run_id,
115
+ )
116
+ if timeout is None:
117
+ raise
118
+ raise timeout from exc
119
+ remaining = deadline - time.monotonic()
120
+ if remaining <= 0:
121
+ raise ArexTimeoutError("poll", timeout_ms, run_id)
122
+ if value is not None:
123
+ return value
124
+ await asyncio.sleep(
125
+ min(_delay_seconds(retry_after_ms, poll_interval_ms), remaining)
126
+ )
arex/_transport.py ADDED
@@ -0,0 +1,303 @@
1
+ """HTTP plumbing: auth, per-call timeouts, credit headers, errors, retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, NamedTuple
8
+
9
+ import httpx
10
+
11
+ from arex._validate import is_timeout_ms, validate_timeout_ms
12
+ from arex.errors import (
13
+ ArexNetworkError,
14
+ ArexTimeoutError,
15
+ RateLimitError,
16
+ ServiceUnavailableError,
17
+ api_error_from_response,
18
+ retry_after_ms_from_headers,
19
+ )
20
+ from arex.models import (
21
+ DEFAULT_MAX_RETRIES,
22
+ DEFAULT_RETRY_DELAY_MS,
23
+ Credits,
24
+ )
25
+
26
+ __all__ = ["ApiResult", "AsyncTransport", "Transport"]
27
+
28
+
29
+ class ApiResult(NamedTuple):
30
+ """One decoded successful response."""
31
+
32
+ data: Any
33
+ credits: Credits | None
34
+ retry_after_ms: int | None
35
+
36
+
37
+ def _prepare(
38
+ base_url: str,
39
+ api_key: str,
40
+ path: str,
41
+ idempotency_key: str | None,
42
+ ) -> tuple[str, dict[str, str]]:
43
+ headers = {
44
+ "Authorization": f"Bearer {api_key}",
45
+ "Accept": "application/json",
46
+ }
47
+ if idempotency_key is not None:
48
+ headers["Idempotency-Key"] = idempotency_key
49
+ return base_url.rstrip("/") + path, headers
50
+
51
+
52
+ def _decode(response: httpx.Response) -> ApiResult:
53
+ retry_after_ms = retry_after_ms_from_headers(response.headers)
54
+
55
+ if not 200 <= response.status_code < 300:
56
+ try:
57
+ body: Any = response.json()
58
+ except ValueError as exc:
59
+ raise ArexNetworkError("invalid_response") from exc
60
+ raise api_error_from_response(response.status_code, body, response.headers)
61
+
62
+ if response.status_code == 204 or not response.content:
63
+ return ApiResult(None, None, retry_after_ms)
64
+
65
+ try:
66
+ data = response.json()
67
+ except ValueError as exc:
68
+ raise ArexNetworkError("invalid_response") from exc
69
+
70
+ return ApiResult(data, Credits.from_headers(response.headers), retry_after_ms)
71
+
72
+
73
+ class _Base:
74
+ def __init__(
75
+ self,
76
+ *,
77
+ api_key: str,
78
+ base_url: str,
79
+ timeout_ms: int | None,
80
+ max_retries: int,
81
+ retry_unavailable: bool,
82
+ ) -> None:
83
+ if timeout_ms is not None and not is_timeout_ms(timeout_ms):
84
+ raise ValueError("timeout_ms must be a positive integer")
85
+ if (
86
+ isinstance(max_retries, bool)
87
+ or not isinstance(max_retries, int)
88
+ or max_retries < 0
89
+ ):
90
+ raise ValueError("max_retries must be a non-negative integer")
91
+ self.api_key = api_key
92
+ self.base_url = base_url
93
+ self.timeout_ms = timeout_ms
94
+ self.max_retries = max_retries
95
+ self.retry_unavailable = retry_unavailable
96
+
97
+ def resolve_timeout(self, default_ms: int, override_ms: int | None) -> int:
98
+ """Per-call argument beats the client-level override beats the default."""
99
+ if override_ms is not None:
100
+ validate_timeout_ms(override_ms)
101
+ return override_ms
102
+ if self.timeout_ms is not None:
103
+ return self.timeout_ms
104
+ return default_ms
105
+
106
+ def _retry_delay_ms(
107
+ self,
108
+ error: RateLimitError | ServiceUnavailableError,
109
+ attempt: int,
110
+ ) -> int | None:
111
+ """Milliseconds to wait before retrying, or `None` to give up."""
112
+ if attempt >= self.max_retries:
113
+ return None
114
+ if isinstance(error, RateLimitError):
115
+ # `outstanding_run_limit` is a state, not a burst; retrying never helps.
116
+ if error.code not in {"rate_limit_exceeded", "idempotency_key_in_progress"}:
117
+ return None
118
+ elif not self.retry_unavailable or error.code != "temporarily_unavailable":
119
+ return None
120
+ if error.retry_after_ms is not None:
121
+ return error.retry_after_ms
122
+ return DEFAULT_RETRY_DELAY_MS
123
+
124
+ def _retry_sleep(
125
+ self,
126
+ error: RateLimitError | ServiceUnavailableError,
127
+ attempt: int,
128
+ deadline: float,
129
+ ) -> float | None:
130
+ """Seconds to sleep, capped to the deadline, or `None` if not retryable."""
131
+ delay_ms = self._retry_delay_ms(error, attempt)
132
+ if delay_ms is None:
133
+ return None
134
+ return min(delay_ms / 1000, max(0.0, deadline - time.monotonic()))
135
+
136
+
137
+ def _attempt_timeout(attempt: int, timeout_ms: int, deadline: float) -> httpx.Timeout:
138
+ """httpx timeouts are per phase, so each attempt gets what is left of the
139
+ one budget (the first gets all of it)."""
140
+ seconds = timeout_ms / 1000 if attempt == 0 else deadline - time.monotonic()
141
+ if seconds <= 0:
142
+ raise ArexTimeoutError("request", timeout_ms)
143
+ return httpx.Timeout(seconds)
144
+
145
+
146
+ class Transport(_Base):
147
+ """Synchronous transport over `httpx.Client`.
148
+
149
+ Each attempt gives its I/O phases the remaining budget. The SDK checks
150
+ elapsed time after a response, but cannot interrupt a blocking call, so
151
+ a server that keeps trickling bytes can outlast the budget.
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ *,
157
+ api_key: str,
158
+ base_url: str,
159
+ timeout_ms: int | None = None,
160
+ max_retries: int = DEFAULT_MAX_RETRIES,
161
+ retry_unavailable: bool = False,
162
+ client: httpx.Client | None = None,
163
+ ) -> None:
164
+ super().__init__(
165
+ api_key=api_key,
166
+ base_url=base_url,
167
+ timeout_ms=timeout_ms,
168
+ max_retries=max_retries,
169
+ retry_unavailable=retry_unavailable,
170
+ )
171
+ self.owns_client = client is None
172
+ self.client = client if client is not None else httpx.Client(base_url=base_url)
173
+
174
+ def request(
175
+ self,
176
+ method: str,
177
+ path: str,
178
+ *,
179
+ json: Any | None = None,
180
+ timeout_ms: int,
181
+ idempotency_key: str | None = None,
182
+ ) -> ApiResult:
183
+ url, headers = _prepare(self.base_url, self.api_key, path, idempotency_key)
184
+ deadline = time.monotonic() + timeout_ms / 1000
185
+ attempt = 0
186
+ while True:
187
+ timeout = _attempt_timeout(attempt, timeout_ms, deadline)
188
+ try:
189
+ response = self.client.request(
190
+ method, url, headers=headers, json=json, timeout=timeout
191
+ )
192
+ except httpx.TimeoutException as exc:
193
+ raise ArexTimeoutError("request", timeout_ms) from exc
194
+ except httpx.HTTPError as exc:
195
+ raise ArexNetworkError("request_failed") from exc
196
+ if time.monotonic() >= deadline:
197
+ raise ArexTimeoutError("request", timeout_ms)
198
+ try:
199
+ return _decode(response)
200
+ except (RateLimitError, ServiceUnavailableError) as error:
201
+ delay = self._retry_sleep(error, attempt, deadline)
202
+ if delay is None:
203
+ raise
204
+ time.sleep(delay)
205
+ attempt += 1
206
+
207
+ def close(self) -> None:
208
+ if self.owns_client:
209
+ self.client.close()
210
+
211
+
212
+ class AsyncTransport(_Base):
213
+ """Asynchronous transport over `httpx.AsyncClient`.
214
+
215
+ The budget is a wall-clock deadline: when it passes, the in-flight request
216
+ or retry sleep is cancelled.
217
+ """
218
+
219
+ def __init__(
220
+ self,
221
+ *,
222
+ api_key: str,
223
+ base_url: str,
224
+ timeout_ms: int | None = None,
225
+ max_retries: int = DEFAULT_MAX_RETRIES,
226
+ retry_unavailable: bool = False,
227
+ client: httpx.AsyncClient | None = None,
228
+ ) -> None:
229
+ super().__init__(
230
+ api_key=api_key,
231
+ base_url=base_url,
232
+ timeout_ms=timeout_ms,
233
+ max_retries=max_retries,
234
+ retry_unavailable=retry_unavailable,
235
+ )
236
+ self.owns_client = client is None
237
+ self.client = (
238
+ client if client is not None else httpx.AsyncClient(base_url=base_url)
239
+ )
240
+
241
+ async def request(
242
+ self,
243
+ method: str,
244
+ path: str,
245
+ *,
246
+ json: Any | None = None,
247
+ timeout_ms: int,
248
+ idempotency_key: str | None = None,
249
+ ) -> ApiResult:
250
+ url, headers = _prepare(self.base_url, self.api_key, path, idempotency_key)
251
+ deadline = time.monotonic() + timeout_ms / 1000
252
+ work = asyncio.ensure_future(
253
+ self._send(method, url, headers, json, timeout_ms, deadline)
254
+ )
255
+ # Not `asyncio.wait_for`: before Python 3.12 it can swallow a caller's
256
+ # cancellation that lands as the request finishes, and `asyncio.timeout`
257
+ # needs 3.11. Waiting on the task keeps both cases explicit.
258
+ try:
259
+ done, _ = await asyncio.wait({work}, timeout=timeout_ms / 1000)
260
+ except asyncio.CancelledError:
261
+ work.cancel()
262
+ await asyncio.gather(work, return_exceptions=True)
263
+ raise
264
+ if work in done:
265
+ return work.result()
266
+ work.cancel()
267
+ await asyncio.gather(work, return_exceptions=True)
268
+ raise ArexTimeoutError("request", timeout_ms)
269
+
270
+ async def _send(
271
+ self,
272
+ method: str,
273
+ url: str,
274
+ headers: dict[str, str],
275
+ json: Any | None,
276
+ timeout_ms: int,
277
+ deadline: float,
278
+ ) -> ApiResult:
279
+ attempt = 0
280
+ while True:
281
+ timeout = _attempt_timeout(attempt, timeout_ms, deadline)
282
+ try:
283
+ response = await self.client.request(
284
+ method, url, headers=headers, json=json, timeout=timeout
285
+ )
286
+ except httpx.TimeoutException as exc:
287
+ raise ArexTimeoutError("request", timeout_ms) from exc
288
+ except httpx.HTTPError as exc:
289
+ raise ArexNetworkError("request_failed") from exc
290
+ if time.monotonic() >= deadline:
291
+ raise ArexTimeoutError("request", timeout_ms)
292
+ try:
293
+ return _decode(response)
294
+ except (RateLimitError, ServiceUnavailableError) as error:
295
+ delay = self._retry_sleep(error, attempt, deadline)
296
+ if delay is None:
297
+ raise
298
+ await asyncio.sleep(delay)
299
+ attempt += 1
300
+
301
+ async def aclose(self) -> None:
302
+ if self.owns_client:
303
+ await self.client.aclose()