ligaxai 0.1.0.dev13__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.
- ligaxai/__init__.py +91 -0
- ligaxai/_base_client.py +437 -0
- ligaxai/_client.py +259 -0
- ligaxai/_exceptions.py +163 -0
- ligaxai/_generated/__init__.py +47 -0
- ligaxai/_generated/models.py +544 -0
- ligaxai/_logging.py +71 -0
- ligaxai/_models.py +31 -0
- ligaxai/_pagination.py +105 -0
- ligaxai/_run_stream.py +527 -0
- ligaxai/_streaming.py +295 -0
- ligaxai/_types.py +67 -0
- ligaxai/_version.py +1 -0
- ligaxai/_webhooks.py +181 -0
- ligaxai/fastapi.py +433 -0
- ligaxai/py.typed +0 -0
- ligaxai/resources/__init__.py +46 -0
- ligaxai/resources/_base.py +67 -0
- ligaxai/resources/client_tokens.py +97 -0
- ligaxai/resources/connect_sessions.py +198 -0
- ligaxai/resources/conversations.py +47 -0
- ligaxai/resources/end_users.py +102 -0
- ligaxai/resources/files.py +171 -0
- ligaxai/resources/keys.py +25 -0
- ligaxai/resources/profiles.py +40 -0
- ligaxai/resources/runs.py +475 -0
- ligaxai/resources/usage.py +69 -0
- ligaxai/resources/webhooks.py +236 -0
- ligaxai/types.py +119 -0
- ligaxai/webhooks.py +36 -0
- ligaxai-0.1.0.dev13.dist-info/METADATA +675 -0
- ligaxai-0.1.0.dev13.dist-info/RECORD +34 -0
- ligaxai-0.1.0.dev13.dist-info/WHEEL +4 -0
- ligaxai-0.1.0.dev13.dist-info/licenses/LICENSE +190 -0
ligaxai/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""LigaX AI Python SDK.
|
|
2
|
+
|
|
3
|
+
from ligaxai import LigaX
|
|
4
|
+
|
|
5
|
+
client = LigaX() # reads LIGAX_API_KEY
|
|
6
|
+
run = client.runs.wait(profile_id="prf_...", input="Hello")
|
|
7
|
+
print(run.output.text)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from . import types
|
|
11
|
+
from ._client import (
|
|
12
|
+
API_KEY_ENV_VAR,
|
|
13
|
+
BASE_URL_ENV_VAR,
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
AsyncLigaX,
|
|
16
|
+
LigaX,
|
|
17
|
+
MissingAPIKeyError,
|
|
18
|
+
)
|
|
19
|
+
from ._exceptions import (
|
|
20
|
+
APIConnectionError,
|
|
21
|
+
APIError,
|
|
22
|
+
APIStatusError,
|
|
23
|
+
APITimeoutError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
BadRequestError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
InternalServerError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PaymentRequiredError,
|
|
30
|
+
PermissionDeniedError,
|
|
31
|
+
RateLimitError,
|
|
32
|
+
UnprocessableEntityError,
|
|
33
|
+
WebhookVerificationError,
|
|
34
|
+
)
|
|
35
|
+
from ._generated.models import Run
|
|
36
|
+
from ._models import BaseModel
|
|
37
|
+
from ._pagination import AsyncPage, SyncPage
|
|
38
|
+
from ._run_stream import (
|
|
39
|
+
AsyncRunStream,
|
|
40
|
+
AsyncRunStreamManager,
|
|
41
|
+
RunEvent,
|
|
42
|
+
RunEventList,
|
|
43
|
+
RunStream,
|
|
44
|
+
TextAssembler,
|
|
45
|
+
)
|
|
46
|
+
from ._streaming import AsyncStream, EventRegistry, ServerSentEvent, Stream
|
|
47
|
+
from ._types import NOT_GIVEN, NotGiven, RequestOptions, Timeout
|
|
48
|
+
from ._version import __version__
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"API_KEY_ENV_VAR",
|
|
52
|
+
"BASE_URL_ENV_VAR",
|
|
53
|
+
"DEFAULT_BASE_URL",
|
|
54
|
+
"NOT_GIVEN",
|
|
55
|
+
"APIConnectionError",
|
|
56
|
+
"APIError",
|
|
57
|
+
"APIStatusError",
|
|
58
|
+
"APITimeoutError",
|
|
59
|
+
"AsyncLigaX",
|
|
60
|
+
"AsyncPage",
|
|
61
|
+
"AsyncRunStream",
|
|
62
|
+
"AsyncRunStreamManager",
|
|
63
|
+
"AsyncStream",
|
|
64
|
+
"AuthenticationError",
|
|
65
|
+
"BadRequestError",
|
|
66
|
+
"BaseModel",
|
|
67
|
+
"ConflictError",
|
|
68
|
+
"EventRegistry",
|
|
69
|
+
"InternalServerError",
|
|
70
|
+
"LigaX",
|
|
71
|
+
"MissingAPIKeyError",
|
|
72
|
+
"NotFoundError",
|
|
73
|
+
"NotGiven",
|
|
74
|
+
"PaymentRequiredError",
|
|
75
|
+
"PermissionDeniedError",
|
|
76
|
+
"RateLimitError",
|
|
77
|
+
"RequestOptions",
|
|
78
|
+
"Run",
|
|
79
|
+
"RunEvent",
|
|
80
|
+
"RunEventList",
|
|
81
|
+
"RunStream",
|
|
82
|
+
"ServerSentEvent",
|
|
83
|
+
"Stream",
|
|
84
|
+
"SyncPage",
|
|
85
|
+
"TextAssembler",
|
|
86
|
+
"Timeout",
|
|
87
|
+
"UnprocessableEntityError",
|
|
88
|
+
"WebhookVerificationError",
|
|
89
|
+
"__version__",
|
|
90
|
+
"types",
|
|
91
|
+
]
|
ligaxai/_base_client.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""The shared request pipeline behind ``LigaX`` and ``AsyncLigaX``.
|
|
2
|
+
|
|
3
|
+
Layering: ``_client`` (public constructors, env, resource mounting) → ``resources/*``
|
|
4
|
+
(one method per endpoint, sync + async) → this module (headers, retries, error mapping,
|
|
5
|
+
parsing). Resources never touch ``httpx2`` directly.
|
|
6
|
+
|
|
7
|
+
Retry policy, in the order it is evaluated:
|
|
8
|
+
|
|
9
|
+
1. ``x-should-retry: true|false`` on the response is an explicit server override.
|
|
10
|
+
2. Transport errors (connect, read, write, timeout) are retried — a POST carries an
|
|
11
|
+
``Idempotency-Key`` that is minted once and reused across attempts, so a replayed
|
|
12
|
+
write is safe.
|
|
13
|
+
3. 408, 429 and every 5xx are retried; a 409 only when the server marks it retryable
|
|
14
|
+
(``x-should-retry: true``, e.g. ``idempotency_in_progress``). 402, 403 and 422 never
|
|
15
|
+
are: payment,
|
|
16
|
+
permission and validation failures (a spend limit, a scope, ``idempotency_key_reused``)
|
|
17
|
+
do not fix themselves on a second attempt — and the server marks none of them retryable.
|
|
18
|
+
4. Nothing is retried once a stream has started — the consumer resumes with
|
|
19
|
+
``last_event_id`` instead.
|
|
20
|
+
|
|
21
|
+
Delay: ``retry-after-ms``, then ``retry-after`` (integer seconds or an HTTP-date), honoured
|
|
22
|
+
when ``0 < value <= 120`` s; otherwise exponential ``0.5 s → 8 s`` with 25 % jitter.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import email.utils
|
|
28
|
+
import json
|
|
29
|
+
import platform
|
|
30
|
+
import random
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from datetime import UTC, datetime
|
|
35
|
+
from typing import Any, TypeAlias, TypeVar
|
|
36
|
+
|
|
37
|
+
import anyio
|
|
38
|
+
import httpx2
|
|
39
|
+
|
|
40
|
+
from ._exceptions import (
|
|
41
|
+
APIConnectionError,
|
|
42
|
+
APIStatusError,
|
|
43
|
+
APITimeoutError,
|
|
44
|
+
AuthenticationError,
|
|
45
|
+
BadRequestError,
|
|
46
|
+
ConflictError,
|
|
47
|
+
InternalServerError,
|
|
48
|
+
NotFoundError,
|
|
49
|
+
PaymentRequiredError,
|
|
50
|
+
PermissionDeniedError,
|
|
51
|
+
RateLimitError,
|
|
52
|
+
UnprocessableEntityError,
|
|
53
|
+
extract_error,
|
|
54
|
+
)
|
|
55
|
+
from ._logging import logger
|
|
56
|
+
from ._models import BaseModel
|
|
57
|
+
from ._pagination import AsyncPage, PageRequest, SyncPage
|
|
58
|
+
from ._streaming import AsyncStream, EventRegistry, Stream
|
|
59
|
+
from ._types import RequestOptions
|
|
60
|
+
from ._version import __version__
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"DEFAULT_MAX_RETRIES",
|
|
64
|
+
"DEFAULT_TIMEOUT",
|
|
65
|
+
"AsyncBaseClient",
|
|
66
|
+
"BaseClient",
|
|
67
|
+
"MultipartFiles",
|
|
68
|
+
"SyncBaseClient",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
DEFAULT_TIMEOUT = httpx2.Timeout(600.0, connect=5.0)
|
|
72
|
+
DEFAULT_MAX_RETRIES = 2
|
|
73
|
+
INITIAL_RETRY_DELAY = 0.5
|
|
74
|
+
MAX_RETRY_DELAY = 8.0
|
|
75
|
+
MAX_RETRY_AFTER = 120.0
|
|
76
|
+
RETRYABLE_STATUS = frozenset({408, 429})
|
|
77
|
+
NEVER_RETRY_STATUS = frozenset({402, 403, 422})
|
|
78
|
+
IDEMPOTENT_METHODS = frozenset({"POST"})
|
|
79
|
+
|
|
80
|
+
USER_AGENT_TEMPLATE = "ligaxai-python/{version} (python/{py}; {os}/{arch})"
|
|
81
|
+
|
|
82
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
83
|
+
|
|
84
|
+
#: Multipart parts as ``{field: (filename, content, content_type)}``. ``content`` is bytes,
|
|
85
|
+
#: never a stream: the request is rebuilt on every retry, and a stream would be empty the
|
|
86
|
+
#: second time.
|
|
87
|
+
MultipartFiles: TypeAlias = dict[str, tuple[str, bytes, str]]
|
|
88
|
+
SyncPageT = TypeVar("SyncPageT", bound="SyncPage[Any]")
|
|
89
|
+
AsyncPageT = TypeVar("AsyncPageT", bound="AsyncPage[Any]")
|
|
90
|
+
|
|
91
|
+
# Module-level so tests can monkeypatch them and run the retry loop without sleeping.
|
|
92
|
+
_sleep = time.sleep
|
|
93
|
+
_async_sleep = anyio.sleep
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _user_agent() -> str:
|
|
97
|
+
return USER_AGENT_TEMPLATE.format(
|
|
98
|
+
version=__version__,
|
|
99
|
+
py=f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
100
|
+
os=sys.platform,
|
|
101
|
+
arch=platform.machine() or "unknown",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BaseClient:
|
|
106
|
+
"""Configuration and the pure (I/O-free) parts of the pipeline."""
|
|
107
|
+
|
|
108
|
+
api_key: str
|
|
109
|
+
base_url: httpx2.URL
|
|
110
|
+
timeout: httpx2.Timeout | float
|
|
111
|
+
max_retries: int
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
api_key: str,
|
|
117
|
+
base_url: str,
|
|
118
|
+
timeout: httpx2.Timeout | float | None,
|
|
119
|
+
max_retries: int,
|
|
120
|
+
default_headers: dict[str, str] | None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.api_key = api_key
|
|
123
|
+
self.base_url = httpx2.URL(base_url if base_url.endswith("/") else base_url + "/")
|
|
124
|
+
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
125
|
+
self.max_retries = max_retries
|
|
126
|
+
self._default_headers: dict[str, str] = dict(default_headers or {})
|
|
127
|
+
|
|
128
|
+
# -- request construction -------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def user_agent(self) -> str:
|
|
132
|
+
return _user_agent()
|
|
133
|
+
|
|
134
|
+
def default_headers(self) -> dict[str, str]:
|
|
135
|
+
return {
|
|
136
|
+
"Accept": "application/json",
|
|
137
|
+
"User-Agent": self.user_agent,
|
|
138
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
139
|
+
**self._default_headers,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def _build_headers(self, method: str, options: RequestOptions, stream: bool) -> dict[str, str]:
|
|
143
|
+
# Merged case-insensitively: a caller's `idempotency-key` or `authorization` replaces
|
|
144
|
+
# ours instead of travelling alongside it as a second header.
|
|
145
|
+
merged: dict[str, tuple[str, str]] = {}
|
|
146
|
+
for source in (self.default_headers(), options.headers):
|
|
147
|
+
for name, value in source.items():
|
|
148
|
+
merged[name.lower()] = (name, value)
|
|
149
|
+
if stream:
|
|
150
|
+
merged["accept"] = ("Accept", "text/event-stream")
|
|
151
|
+
if method.upper() in IDEMPOTENT_METHODS and "idempotency-key" not in merged:
|
|
152
|
+
merged["idempotency-key"] = (
|
|
153
|
+
"Idempotency-Key",
|
|
154
|
+
options.idempotency_key or f"sdk-{uuid.uuid4()}",
|
|
155
|
+
)
|
|
156
|
+
return dict(merged.values())
|
|
157
|
+
|
|
158
|
+
def _build_url(self, path: str) -> httpx2.URL:
|
|
159
|
+
return self.base_url.join(path.lstrip("/"))
|
|
160
|
+
|
|
161
|
+
# -- retry decisions -------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _should_retry(self, response: httpx2.Response) -> bool:
|
|
164
|
+
override = response.headers.get("x-should-retry", "").strip().lower()
|
|
165
|
+
if override == "true":
|
|
166
|
+
return True
|
|
167
|
+
if override == "false":
|
|
168
|
+
return False
|
|
169
|
+
status = response.status_code
|
|
170
|
+
if status in NEVER_RETRY_STATUS:
|
|
171
|
+
return False
|
|
172
|
+
return status in RETRYABLE_STATUS or status >= 500
|
|
173
|
+
|
|
174
|
+
def _retry_delay(self, retries_taken: int, response: httpx2.Response | None) -> float:
|
|
175
|
+
if response is not None:
|
|
176
|
+
hinted = _parse_retry_after(response.headers)
|
|
177
|
+
if hinted is not None and 0 < hinted <= MAX_RETRY_AFTER:
|
|
178
|
+
return hinted
|
|
179
|
+
base = min(INITIAL_RETRY_DELAY * (2**retries_taken), MAX_RETRY_DELAY)
|
|
180
|
+
jitter: float = 1 - 0.25 * random.random()
|
|
181
|
+
return float(base * jitter)
|
|
182
|
+
|
|
183
|
+
# -- response handling -----------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _make_status_error(self, response: httpx2.Response) -> APIStatusError:
|
|
186
|
+
body = _safe_json(response)
|
|
187
|
+
raw_message = extract_error(body).get("message")
|
|
188
|
+
message: str | None = raw_message if isinstance(raw_message, str) else None
|
|
189
|
+
if message is None:
|
|
190
|
+
message = response.text.strip() or f"Error code: {response.status_code}"
|
|
191
|
+
cls = _status_error_class(response.status_code)
|
|
192
|
+
return cls(message, response=response, body=body)
|
|
193
|
+
|
|
194
|
+
def _parse(self, response: httpx2.Response, cast_to: type[ModelT]) -> ModelT:
|
|
195
|
+
value = cast_to.model_validate(_safe_json(response))
|
|
196
|
+
value.attach_request_id(response.headers.get("x-request-id"))
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class SyncBaseClient(BaseClient):
|
|
201
|
+
_http: httpx2.Client
|
|
202
|
+
|
|
203
|
+
def request(
|
|
204
|
+
self,
|
|
205
|
+
method: str,
|
|
206
|
+
path: str,
|
|
207
|
+
*,
|
|
208
|
+
cast_to: type[ModelT],
|
|
209
|
+
json: Any = None,
|
|
210
|
+
files: MultipartFiles | None = None,
|
|
211
|
+
data: dict[str, str] | None = None,
|
|
212
|
+
options: RequestOptions | None = None,
|
|
213
|
+
) -> ModelT:
|
|
214
|
+
response = self._send_with_retries(
|
|
215
|
+
method, path, json=json, files=files, data=data, options=options, stream=False
|
|
216
|
+
)
|
|
217
|
+
return self._parse(response, cast_to)
|
|
218
|
+
|
|
219
|
+
def request_stream(
|
|
220
|
+
self,
|
|
221
|
+
method: str,
|
|
222
|
+
path: str,
|
|
223
|
+
*,
|
|
224
|
+
json: Any = None,
|
|
225
|
+
options: RequestOptions | None = None,
|
|
226
|
+
registry: EventRegistry[ModelT] | None = None,
|
|
227
|
+
) -> Stream[ModelT]:
|
|
228
|
+
response = self._send_with_retries(method, path, json=json, options=options, stream=True)
|
|
229
|
+
return Stream(response=response, registry=registry)
|
|
230
|
+
|
|
231
|
+
def request_page(self, page_cls: type[SyncPageT], req: PageRequest) -> SyncPageT:
|
|
232
|
+
response = self._send_with_retries(req.method, req.path, json=req.json, options=req.options)
|
|
233
|
+
page = self._parse(response, page_cls)
|
|
234
|
+
page.bind(self, req)
|
|
235
|
+
return page
|
|
236
|
+
|
|
237
|
+
def _send_with_retries(
|
|
238
|
+
self,
|
|
239
|
+
method: str,
|
|
240
|
+
path: str,
|
|
241
|
+
*,
|
|
242
|
+
json: Any,
|
|
243
|
+
options: RequestOptions | None,
|
|
244
|
+
stream: bool = False,
|
|
245
|
+
files: MultipartFiles | None = None,
|
|
246
|
+
data: dict[str, str] | None = None,
|
|
247
|
+
) -> httpx2.Response:
|
|
248
|
+
opts = options or RequestOptions()
|
|
249
|
+
max_retries = self.max_retries if opts.max_retries is None else opts.max_retries
|
|
250
|
+
headers = self._build_headers(method, opts, stream)
|
|
251
|
+
timeout = self.timeout if opts.timeout is None else opts.timeout
|
|
252
|
+
retries_taken = 0
|
|
253
|
+
while True:
|
|
254
|
+
request = self._http.build_request(
|
|
255
|
+
method,
|
|
256
|
+
self._build_url(path),
|
|
257
|
+
json=json,
|
|
258
|
+
files=files,
|
|
259
|
+
data=data,
|
|
260
|
+
params=opts.params or None,
|
|
261
|
+
headers=headers,
|
|
262
|
+
timeout=timeout,
|
|
263
|
+
)
|
|
264
|
+
logger.debug("Request %s %s (attempt %d)", method, request.url, retries_taken + 1)
|
|
265
|
+
try:
|
|
266
|
+
response = self._http.send(request, stream=stream)
|
|
267
|
+
except httpx2.TimeoutException as exc:
|
|
268
|
+
if retries_taken >= max_retries:
|
|
269
|
+
raise APITimeoutError(request) from exc
|
|
270
|
+
_sleep(self._retry_delay(retries_taken, None))
|
|
271
|
+
retries_taken += 1
|
|
272
|
+
continue
|
|
273
|
+
except httpx2.TransportError as exc:
|
|
274
|
+
if retries_taken >= max_retries:
|
|
275
|
+
raise APIConnectionError(request=request) from exc
|
|
276
|
+
_sleep(self._retry_delay(retries_taken, None))
|
|
277
|
+
retries_taken += 1
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if response.is_success:
|
|
281
|
+
return response
|
|
282
|
+
|
|
283
|
+
if retries_taken < max_retries and self._should_retry(response):
|
|
284
|
+
response.close()
|
|
285
|
+
_sleep(self._retry_delay(retries_taken, response))
|
|
286
|
+
retries_taken += 1
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
response.read()
|
|
290
|
+
raise self._make_status_error(response)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class AsyncBaseClient(BaseClient):
|
|
294
|
+
_http: httpx2.AsyncClient
|
|
295
|
+
|
|
296
|
+
async def request(
|
|
297
|
+
self,
|
|
298
|
+
method: str,
|
|
299
|
+
path: str,
|
|
300
|
+
*,
|
|
301
|
+
cast_to: type[ModelT],
|
|
302
|
+
json: Any = None,
|
|
303
|
+
files: MultipartFiles | None = None,
|
|
304
|
+
data: dict[str, str] | None = None,
|
|
305
|
+
options: RequestOptions | None = None,
|
|
306
|
+
) -> ModelT:
|
|
307
|
+
response = await self._send_with_retries(
|
|
308
|
+
method, path, json=json, files=files, data=data, options=options, stream=False
|
|
309
|
+
)
|
|
310
|
+
return self._parse(response, cast_to)
|
|
311
|
+
|
|
312
|
+
async def request_stream(
|
|
313
|
+
self,
|
|
314
|
+
method: str,
|
|
315
|
+
path: str,
|
|
316
|
+
*,
|
|
317
|
+
json: Any = None,
|
|
318
|
+
options: RequestOptions | None = None,
|
|
319
|
+
registry: EventRegistry[ModelT] | None = None,
|
|
320
|
+
) -> AsyncStream[ModelT]:
|
|
321
|
+
response = await self._send_with_retries(
|
|
322
|
+
method, path, json=json, options=options, stream=True
|
|
323
|
+
)
|
|
324
|
+
return AsyncStream(response=response, registry=registry)
|
|
325
|
+
|
|
326
|
+
async def request_page(self, page_cls: type[AsyncPageT], req: PageRequest) -> AsyncPageT:
|
|
327
|
+
response = await self._send_with_retries(
|
|
328
|
+
req.method, req.path, json=req.json, options=req.options
|
|
329
|
+
)
|
|
330
|
+
page = self._parse(response, page_cls)
|
|
331
|
+
page.bind(self, req)
|
|
332
|
+
return page
|
|
333
|
+
|
|
334
|
+
async def _send_with_retries(
|
|
335
|
+
self,
|
|
336
|
+
method: str,
|
|
337
|
+
path: str,
|
|
338
|
+
*,
|
|
339
|
+
json: Any,
|
|
340
|
+
options: RequestOptions | None,
|
|
341
|
+
stream: bool = False,
|
|
342
|
+
files: MultipartFiles | None = None,
|
|
343
|
+
data: dict[str, str] | None = None,
|
|
344
|
+
) -> httpx2.Response:
|
|
345
|
+
opts = options or RequestOptions()
|
|
346
|
+
max_retries = self.max_retries if opts.max_retries is None else opts.max_retries
|
|
347
|
+
headers = self._build_headers(method, opts, stream)
|
|
348
|
+
timeout = self.timeout if opts.timeout is None else opts.timeout
|
|
349
|
+
retries_taken = 0
|
|
350
|
+
while True:
|
|
351
|
+
request = self._http.build_request(
|
|
352
|
+
method,
|
|
353
|
+
self._build_url(path),
|
|
354
|
+
json=json,
|
|
355
|
+
files=files,
|
|
356
|
+
data=data,
|
|
357
|
+
params=opts.params or None,
|
|
358
|
+
headers=headers,
|
|
359
|
+
timeout=timeout,
|
|
360
|
+
)
|
|
361
|
+
logger.debug("Request %s %s (attempt %d)", method, request.url, retries_taken + 1)
|
|
362
|
+
try:
|
|
363
|
+
response = await self._http.send(request, stream=stream)
|
|
364
|
+
except httpx2.TimeoutException as exc:
|
|
365
|
+
if retries_taken >= max_retries:
|
|
366
|
+
raise APITimeoutError(request) from exc
|
|
367
|
+
await _async_sleep(self._retry_delay(retries_taken, None))
|
|
368
|
+
retries_taken += 1
|
|
369
|
+
continue
|
|
370
|
+
except httpx2.TransportError as exc:
|
|
371
|
+
if retries_taken >= max_retries:
|
|
372
|
+
raise APIConnectionError(request=request) from exc
|
|
373
|
+
await _async_sleep(self._retry_delay(retries_taken, None))
|
|
374
|
+
retries_taken += 1
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
if response.is_success:
|
|
378
|
+
return response
|
|
379
|
+
|
|
380
|
+
if retries_taken < max_retries and self._should_retry(response):
|
|
381
|
+
await response.aclose()
|
|
382
|
+
await _async_sleep(self._retry_delay(retries_taken, response))
|
|
383
|
+
retries_taken += 1
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
await response.aread()
|
|
387
|
+
raise self._make_status_error(response)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# -- helpers ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _status_error_class(status: int) -> type[APIStatusError]:
|
|
394
|
+
mapping: dict[int, type[APIStatusError]] = {
|
|
395
|
+
400: BadRequestError,
|
|
396
|
+
401: AuthenticationError,
|
|
397
|
+
402: PaymentRequiredError,
|
|
398
|
+
403: PermissionDeniedError,
|
|
399
|
+
404: NotFoundError,
|
|
400
|
+
409: ConflictError,
|
|
401
|
+
422: UnprocessableEntityError,
|
|
402
|
+
429: RateLimitError,
|
|
403
|
+
}
|
|
404
|
+
if status >= 500:
|
|
405
|
+
return InternalServerError
|
|
406
|
+
return mapping.get(status, APIStatusError)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _safe_json(response: httpx2.Response) -> Any:
|
|
410
|
+
try:
|
|
411
|
+
return response.json()
|
|
412
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _parse_retry_after(headers: httpx2.Headers) -> float | None:
|
|
417
|
+
"""``retry-after-ms`` first, then ``retry-after`` as seconds or an HTTP-date."""
|
|
418
|
+
ms = headers.get("retry-after-ms")
|
|
419
|
+
if ms is not None:
|
|
420
|
+
try:
|
|
421
|
+
return float(ms) / 1000
|
|
422
|
+
except ValueError:
|
|
423
|
+
pass
|
|
424
|
+
raw = headers.get("retry-after")
|
|
425
|
+
if raw is None:
|
|
426
|
+
return None
|
|
427
|
+
try:
|
|
428
|
+
return float(raw)
|
|
429
|
+
except ValueError:
|
|
430
|
+
pass
|
|
431
|
+
try:
|
|
432
|
+
when = email.utils.parsedate_to_datetime(raw)
|
|
433
|
+
except (TypeError, ValueError):
|
|
434
|
+
return None
|
|
435
|
+
if when.tzinfo is None:
|
|
436
|
+
when = when.replace(tzinfo=UTC)
|
|
437
|
+
return (when - datetime.now(UTC)).total_seconds()
|