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 +137 -0
- arex/_poll.py +126 -0
- arex/_transport.py +303 -0
- arex/_validate.py +156 -0
- arex/_wire.py +126 -0
- arex/async_client.py +133 -0
- arex/client.py +197 -0
- arex/errors.py +334 -0
- arex/models.py +414 -0
- arex/py.typed +0 -0
- arex/research.py +363 -0
- arex_python_sdk-0.2.0.dist-info/METADATA +55 -0
- arex_python_sdk-0.2.0.dist-info/RECORD +15 -0
- arex_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- arex_python_sdk-0.2.0.dist-info/licenses/LICENSE +203 -0
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()
|