clocked-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.
- clocked_sdk/__init__.py +46 -0
- clocked_sdk/_version.py +1 -0
- clocked_sdk/async_client.py +472 -0
- clocked_sdk/client.py +892 -0
- clocked_sdk/errors.py +165 -0
- clocked_sdk/py.typed +0 -0
- clocked_sdk-0.2.0.dist-info/METADATA +354 -0
- clocked_sdk-0.2.0.dist-info/RECORD +10 -0
- clocked_sdk-0.2.0.dist-info/WHEEL +4 -0
- clocked_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
clocked_sdk/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Python client for the Clocked API (https://clocked.rezolve.ai).
|
|
2
|
+
|
|
3
|
+
>>> from clocked_sdk import Clocked
|
|
4
|
+
>>> client = Clocked() # reads CLOCKED_API_KEY
|
|
5
|
+
>>> result = client.decide(
|
|
6
|
+
... state="My laptop will not turn on, even when connected to its charger.",
|
|
7
|
+
... questions={"needs_it_help": {"type": "noul", "instructions": "Does the requester need help with an IT problem?"}},
|
|
8
|
+
... )
|
|
9
|
+
>>> result["answers"], result.model_ms
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .async_client import AsyncClocked
|
|
14
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT, Clocked, Result
|
|
15
|
+
from .errors import (
|
|
16
|
+
APIConnectionError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
ClockedError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
InsufficientCreditsError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
RequestTimeoutError,
|
|
24
|
+
ServerError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"Clocked",
|
|
31
|
+
"AsyncClocked",
|
|
32
|
+
"Result",
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"DEFAULT_TIMEOUT",
|
|
35
|
+
"DEFAULT_MAX_ATTEMPTS",
|
|
36
|
+
"ClockedError",
|
|
37
|
+
"ValidationError",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"InsufficientCreditsError",
|
|
40
|
+
"PermissionDeniedError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"RateLimitError",
|
|
43
|
+
"ServerError",
|
|
44
|
+
"APIConnectionError",
|
|
45
|
+
"RequestTimeoutError",
|
|
46
|
+
]
|
clocked_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""Native asynchronous Clocked client, sharing the synchronous client's contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .client import (
|
|
14
|
+
DEFAULT_BASE_URL, DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT,
|
|
15
|
+
Images, JSONObject, Result, _BaseClocked, _DISCONNECTS, _TransportFailure, _retry_after, _retryable,
|
|
16
|
+
)
|
|
17
|
+
from .errors import ClockedError, _error_for_response
|
|
18
|
+
|
|
19
|
+
__all__ = ["AsyncClocked"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncClocked(_BaseClocked):
|
|
23
|
+
"""Asynchronous Clocked API client with the same arguments and results as Clocked.
|
|
24
|
+
|
|
25
|
+
Share one client across concurrent asyncio tasks on the same event loop.
|
|
26
|
+
Every endpoint is awaited. Use ``async with`` or ``await client.aclose()``
|
|
27
|
+
after outstanding requests finish to release the connection pool. Closing
|
|
28
|
+
a client leaves it reusable, like ``Clocked.close()``.
|
|
29
|
+
|
|
30
|
+
Constructor options, error classes, retries, and per-call idempotency
|
|
31
|
+
semantics match Clocked. Cancellation propagates without a retry; stopping
|
|
32
|
+
the local await does not cancel work already accepted by the server.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
42
|
+
retry_base_delay: float = 0.5,
|
|
43
|
+
retry_max_delay: float = 8.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(
|
|
46
|
+
api_key, base_url=base_url, timeout=timeout, max_attempts=max_attempts,
|
|
47
|
+
retry_base_delay=retry_base_delay, retry_max_delay=retry_max_delay,
|
|
48
|
+
)
|
|
49
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return f"AsyncClocked(base_url={self.base_url!r})"
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> AsyncClocked:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
58
|
+
await self.aclose()
|
|
59
|
+
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
"""Close this client's connections. The client stays usable."""
|
|
62
|
+
client, self._http = self._http, None
|
|
63
|
+
if client is not None:
|
|
64
|
+
await client.aclose()
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
"""Alias for :meth:`aclose`; await it to release connections."""
|
|
68
|
+
await self.aclose()
|
|
69
|
+
|
|
70
|
+
# Endpoints ---------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
async def decide(
|
|
73
|
+
self,
|
|
74
|
+
request: Optional[JSONObject] = None,
|
|
75
|
+
/,
|
|
76
|
+
*,
|
|
77
|
+
state: Union[str, Mapping[str, Any], Sequence[Any], None] = None,
|
|
78
|
+
questions: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
79
|
+
images: Optional[Images] = None,
|
|
80
|
+
instructions: Optional[str] = None,
|
|
81
|
+
calibration_temperature: Optional[float] = None,
|
|
82
|
+
rotations: Optional[int] = None,
|
|
83
|
+
documents: Optional[Sequence[Mapping[str, str]]] = None,
|
|
84
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
85
|
+
timeout: Optional[float] = None,
|
|
86
|
+
max_attempts: Optional[int] = None,
|
|
87
|
+
) -> Result:
|
|
88
|
+
"""Answer yes/no (``noul``), choice, multiselect and score questions.
|
|
89
|
+
|
|
90
|
+
``POST /clocked/decide``. Pass ``state`` (text, an object or a list;
|
|
91
|
+
an empty state is answered from the question alone) or ``images``,
|
|
92
|
+
and ``questions`` keyed by name. Returns ``{model, answers, usage}``.
|
|
93
|
+
"""
|
|
94
|
+
return await self._call(
|
|
95
|
+
"decide",
|
|
96
|
+
request,
|
|
97
|
+
idempotency_key,
|
|
98
|
+
timeout,
|
|
99
|
+
max_attempts,
|
|
100
|
+
state=state,
|
|
101
|
+
questions=questions,
|
|
102
|
+
images=images,
|
|
103
|
+
instructions=instructions,
|
|
104
|
+
calibration_temperature=calibration_temperature,
|
|
105
|
+
rotations=rotations,
|
|
106
|
+
documents=documents,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def extract(
|
|
110
|
+
self,
|
|
111
|
+
request: Optional[JSONObject] = None,
|
|
112
|
+
/,
|
|
113
|
+
*,
|
|
114
|
+
text: Optional[str] = None,
|
|
115
|
+
images: Optional[Images] = None,
|
|
116
|
+
fields: Optional[Mapping[str, Union[str, Mapping[str, Any]]]] = None,
|
|
117
|
+
instructions: Optional[str] = None,
|
|
118
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
119
|
+
timeout: Optional[float] = None,
|
|
120
|
+
max_attempts: Optional[int] = None,
|
|
121
|
+
) -> Result:
|
|
122
|
+
"""Extract values verbatim from ``text`` or ``images``.
|
|
123
|
+
|
|
124
|
+
``POST /clocked/extract``. Each of ``fields`` (1 to 64) is a
|
|
125
|
+
description string, an empty dict, or a dict with ``description``,
|
|
126
|
+
``list`` and ``max_items``.
|
|
127
|
+
"""
|
|
128
|
+
return await self._call(
|
|
129
|
+
"extract",
|
|
130
|
+
request,
|
|
131
|
+
idempotency_key,
|
|
132
|
+
timeout,
|
|
133
|
+
max_attempts,
|
|
134
|
+
text=text,
|
|
135
|
+
images=images,
|
|
136
|
+
fields=fields,
|
|
137
|
+
instructions=instructions,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def fill(
|
|
141
|
+
self,
|
|
142
|
+
request: Optional[JSONObject] = None,
|
|
143
|
+
/,
|
|
144
|
+
*,
|
|
145
|
+
text: Optional[str] = None,
|
|
146
|
+
images: Optional[Images] = None,
|
|
147
|
+
fields: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
148
|
+
instructions: Optional[str] = None,
|
|
149
|
+
reference_date: Optional[str] = None,
|
|
150
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
151
|
+
timeout: Optional[float] = None,
|
|
152
|
+
max_attempts: Optional[int] = None,
|
|
153
|
+
) -> Result:
|
|
154
|
+
"""Fill a complete record: extract, choice, multiselect, score, yes_no and generate fields.
|
|
155
|
+
|
|
156
|
+
``POST /clocked/fill``. ``reference_date`` (``YYYY-MM-DD``) anchors
|
|
157
|
+
relative dates for generated date fields.
|
|
158
|
+
"""
|
|
159
|
+
return await self._call(
|
|
160
|
+
"fill",
|
|
161
|
+
request,
|
|
162
|
+
idempotency_key,
|
|
163
|
+
timeout,
|
|
164
|
+
max_attempts,
|
|
165
|
+
text=text,
|
|
166
|
+
images=images,
|
|
167
|
+
fields=fields,
|
|
168
|
+
instructions=instructions,
|
|
169
|
+
reference_date=reference_date,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def pii(
|
|
173
|
+
self,
|
|
174
|
+
request: Optional[JSONObject] = None,
|
|
175
|
+
/,
|
|
176
|
+
*,
|
|
177
|
+
text: Optional[str] = None,
|
|
178
|
+
images: Optional[Images] = None,
|
|
179
|
+
entities: Union[Sequence[str], Mapping[str, Any], None] = None,
|
|
180
|
+
allow: Optional[Sequence[str]] = None,
|
|
181
|
+
deny: Union[Sequence[str], Mapping[str, str], None] = None,
|
|
182
|
+
anonymize: Union[str, Mapping[str, str], None] = None,
|
|
183
|
+
threshold: Optional[float] = None,
|
|
184
|
+
mask_keep: Optional[int] = None,
|
|
185
|
+
max_items: Optional[int] = None,
|
|
186
|
+
instructions: Optional[str] = None,
|
|
187
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
188
|
+
timeout: Optional[float] = None,
|
|
189
|
+
max_attempts: Optional[int] = None,
|
|
190
|
+
) -> Result:
|
|
191
|
+
"""Find personal data and return a transformed copy of the text.
|
|
192
|
+
|
|
193
|
+
``POST /clocked/pii``. ``entities`` lists catalog kinds or maps kinds
|
|
194
|
+
to custom descriptions (default: nine catalog kinds). ``anonymize`` is
|
|
195
|
+
``replace``, ``mask``, ``hash``, ``redact`` or a per-kind mapping.
|
|
196
|
+
"""
|
|
197
|
+
return await self._call(
|
|
198
|
+
"pii",
|
|
199
|
+
request,
|
|
200
|
+
idempotency_key,
|
|
201
|
+
timeout,
|
|
202
|
+
max_attempts,
|
|
203
|
+
text=text,
|
|
204
|
+
images=images,
|
|
205
|
+
entities=entities,
|
|
206
|
+
allow=allow,
|
|
207
|
+
deny=deny,
|
|
208
|
+
anonymize=anonymize,
|
|
209
|
+
threshold=threshold,
|
|
210
|
+
mask_keep=mask_keep,
|
|
211
|
+
max_items=max_items,
|
|
212
|
+
instructions=instructions,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def injection(
|
|
216
|
+
self,
|
|
217
|
+
request: Optional[JSONObject] = None,
|
|
218
|
+
/,
|
|
219
|
+
*,
|
|
220
|
+
text: Optional[str] = None,
|
|
221
|
+
images: Optional[Images] = None,
|
|
222
|
+
source: Optional[str] = None,
|
|
223
|
+
categories: Optional[Sequence[str]] = None,
|
|
224
|
+
instructions: Optional[str] = None,
|
|
225
|
+
threshold: Optional[float] = None,
|
|
226
|
+
evidence: Optional[bool] = None,
|
|
227
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
228
|
+
timeout: Optional[float] = None,
|
|
229
|
+
max_attempts: Optional[int] = None,
|
|
230
|
+
) -> Result:
|
|
231
|
+
"""Detect prompt injection in a user message or in retrieved content.
|
|
232
|
+
|
|
233
|
+
``POST /clocked/injection``. ``source`` is ``user`` (default) or
|
|
234
|
+
``content``.
|
|
235
|
+
"""
|
|
236
|
+
return await self._call(
|
|
237
|
+
"injection",
|
|
238
|
+
request,
|
|
239
|
+
idempotency_key,
|
|
240
|
+
timeout,
|
|
241
|
+
max_attempts,
|
|
242
|
+
text=text,
|
|
243
|
+
images=images,
|
|
244
|
+
source=source,
|
|
245
|
+
categories=categories,
|
|
246
|
+
instructions=instructions,
|
|
247
|
+
threshold=threshold,
|
|
248
|
+
evidence=evidence,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
async def script(
|
|
252
|
+
self,
|
|
253
|
+
request: Optional[JSONObject] = None,
|
|
254
|
+
/,
|
|
255
|
+
*,
|
|
256
|
+
script: Optional[str] = None,
|
|
257
|
+
images: Optional[Images] = None,
|
|
258
|
+
categories: Optional[Sequence[str]] = None,
|
|
259
|
+
instructions: Optional[str] = None,
|
|
260
|
+
threshold: Optional[float] = None,
|
|
261
|
+
evidence: Optional[bool] = None,
|
|
262
|
+
rotations: Optional[int] = None,
|
|
263
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
264
|
+
timeout: Optional[float] = None,
|
|
265
|
+
max_attempts: Optional[int] = None,
|
|
266
|
+
) -> Result:
|
|
267
|
+
"""Classify whether a script's behavior is harmful, with the lines that explain it.
|
|
268
|
+
|
|
269
|
+
``POST /clocked/script``. The script is read as data; nothing runs.
|
|
270
|
+
"""
|
|
271
|
+
return await self._call(
|
|
272
|
+
"script",
|
|
273
|
+
request,
|
|
274
|
+
idempotency_key,
|
|
275
|
+
timeout,
|
|
276
|
+
max_attempts,
|
|
277
|
+
script=script,
|
|
278
|
+
images=images,
|
|
279
|
+
categories=categories,
|
|
280
|
+
instructions=instructions,
|
|
281
|
+
threshold=threshold,
|
|
282
|
+
evidence=evidence,
|
|
283
|
+
rotations=rotations,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def grounding(
|
|
287
|
+
self,
|
|
288
|
+
request: Optional[JSONObject] = None,
|
|
289
|
+
/,
|
|
290
|
+
*,
|
|
291
|
+
documents: Optional[Sequence[Union[str, Mapping[str, Any]]]] = None,
|
|
292
|
+
images: Optional[Images] = None,
|
|
293
|
+
answer: Optional[str] = None,
|
|
294
|
+
question: Optional[str] = None,
|
|
295
|
+
claims: Optional[Sequence[str]] = None,
|
|
296
|
+
instructions: Optional[str] = None,
|
|
297
|
+
threshold: Optional[float] = None,
|
|
298
|
+
evidence: Optional[str] = None,
|
|
299
|
+
rotations: Optional[int] = None,
|
|
300
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
301
|
+
timeout: Optional[float] = None,
|
|
302
|
+
max_attempts: Optional[int] = None,
|
|
303
|
+
) -> Result:
|
|
304
|
+
"""Check each claim in ``answer`` against ``documents`` or ``images``.
|
|
305
|
+
|
|
306
|
+
``POST /clocked/grounding``. ``evidence`` is ``all``, ``flagged``
|
|
307
|
+
(default) or ``none``.
|
|
308
|
+
"""
|
|
309
|
+
return await self._call(
|
|
310
|
+
"grounding",
|
|
311
|
+
request,
|
|
312
|
+
idempotency_key,
|
|
313
|
+
timeout,
|
|
314
|
+
max_attempts,
|
|
315
|
+
documents=documents,
|
|
316
|
+
images=images,
|
|
317
|
+
answer=answer,
|
|
318
|
+
question=question,
|
|
319
|
+
claims=claims,
|
|
320
|
+
instructions=instructions,
|
|
321
|
+
threshold=threshold,
|
|
322
|
+
evidence=evidence,
|
|
323
|
+
rotations=rotations,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def compare(
|
|
327
|
+
self,
|
|
328
|
+
request: Optional[JSONObject] = None,
|
|
329
|
+
/,
|
|
330
|
+
*,
|
|
331
|
+
documents: Optional[Sequence[Union[str, Mapping[str, Any]]]] = None,
|
|
332
|
+
threshold: Optional[float] = None,
|
|
333
|
+
evidence: Optional[str] = None,
|
|
334
|
+
rotations: Optional[int] = None,
|
|
335
|
+
kinds: Optional[bool] = None,
|
|
336
|
+
debug: Optional[bool] = None,
|
|
337
|
+
instructions: Optional[str] = None,
|
|
338
|
+
idempotency_key: Union[str, uuid.UUID, None] = None,
|
|
339
|
+
timeout: Optional[float] = None,
|
|
340
|
+
max_attempts: Optional[int] = None,
|
|
341
|
+
) -> Result:
|
|
342
|
+
"""Find duplicates, shared facts and conflicts across 2 to 20 text documents.
|
|
343
|
+
|
|
344
|
+
``POST /clocked/compare``. ``evidence`` is ``flagged`` (default),
|
|
345
|
+
``all`` or ``none``. Compare reads text only.
|
|
346
|
+
"""
|
|
347
|
+
return await self._call(
|
|
348
|
+
"compare",
|
|
349
|
+
request,
|
|
350
|
+
idempotency_key,
|
|
351
|
+
timeout,
|
|
352
|
+
max_attempts,
|
|
353
|
+
documents=documents,
|
|
354
|
+
threshold=threshold,
|
|
355
|
+
evidence=evidence,
|
|
356
|
+
rotations=rotations,
|
|
357
|
+
kinds=kinds,
|
|
358
|
+
debug=debug,
|
|
359
|
+
instructions=instructions,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
async def _call(
|
|
363
|
+
self,
|
|
364
|
+
endpoint: str,
|
|
365
|
+
request: Optional[JSONObject],
|
|
366
|
+
idempotency_key: Union[str, uuid.UUID, None],
|
|
367
|
+
timeout: Optional[float],
|
|
368
|
+
max_attempts: Optional[int],
|
|
369
|
+
/,
|
|
370
|
+
**fields: Any,
|
|
371
|
+
) -> Result:
|
|
372
|
+
# Only local serialization/file reads use a worker. HTTP and backoff
|
|
373
|
+
# run natively on the event loop, with cancellation between attempts.
|
|
374
|
+
payload, key, wait, attempts = await asyncio.to_thread(
|
|
375
|
+
self._prepare, request, idempotency_key, timeout, max_attempts, **fields
|
|
376
|
+
)
|
|
377
|
+
return await self._send(endpoint, payload, key, wait, attempts)
|
|
378
|
+
|
|
379
|
+
async def _send(self, endpoint: str, payload: bytes, key: str, timeout: float, max_attempts: int) -> Result:
|
|
380
|
+
outcome_unknown = False
|
|
381
|
+
refreshed = False
|
|
382
|
+
attempt = 0
|
|
383
|
+
while True:
|
|
384
|
+
attempt += 1
|
|
385
|
+
retry_after: Optional[float] = None
|
|
386
|
+
try:
|
|
387
|
+
status, headers, raw = await self._post(endpoint, payload, key, timeout)
|
|
388
|
+
except _TransportFailure as transport:
|
|
389
|
+
cause = transport.cause
|
|
390
|
+
if transport.reused and not refreshed and _is_disconnect(cause):
|
|
391
|
+
# Match Clocked's one immediate, uncounted refresh of a
|
|
392
|
+
# reused connection. The same key safely recovers its work.
|
|
393
|
+
refreshed = True
|
|
394
|
+
attempt -= 1
|
|
395
|
+
outcome_unknown = True
|
|
396
|
+
continue
|
|
397
|
+
failure: ClockedError = self._connection_error(
|
|
398
|
+
cause, key, attempt, timeout, timed_out=isinstance(cause, httpx.TimeoutException)
|
|
399
|
+
)
|
|
400
|
+
retry = True
|
|
401
|
+
outcome_unknown = True
|
|
402
|
+
else:
|
|
403
|
+
if 200 <= status < 300:
|
|
404
|
+
return self._result(status, headers, raw, key, attempt)
|
|
405
|
+
failure = _error_for_response(status, headers, raw, idempotency_key=key, attempts=attempt)
|
|
406
|
+
retry = _retryable(status, failure.retry_as_new, outcome_unknown)
|
|
407
|
+
if status >= 500 and not failure.retry_as_new:
|
|
408
|
+
outcome_unknown = True
|
|
409
|
+
retry_after = _retry_after(headers)
|
|
410
|
+
if not retry or attempt >= max_attempts:
|
|
411
|
+
raise failure
|
|
412
|
+
await asyncio.sleep(self._delay(attempt, retry_after))
|
|
413
|
+
|
|
414
|
+
def _connection(self) -> httpx.AsyncClient:
|
|
415
|
+
if self._http is None:
|
|
416
|
+
proxy = self._proxy
|
|
417
|
+
proxy_url = None
|
|
418
|
+
if proxy is not None:
|
|
419
|
+
host = f"[{proxy.host}]" if ":" in proxy.host else proxy.host
|
|
420
|
+
proxy_url = httpx.Proxy(f"http://{host}:{proxy.port}", headers=proxy.headers)
|
|
421
|
+
# SDK retry state is the only retry policy. Proxy selection and TLS
|
|
422
|
+
# context are shared with the sync client, including OS proxy rules.
|
|
423
|
+
transport = httpx.AsyncHTTPTransport(
|
|
424
|
+
verify=self._ssl if self._ssl is not None else True,
|
|
425
|
+
proxy=proxy_url,
|
|
426
|
+
retries=0,
|
|
427
|
+
trust_env=False,
|
|
428
|
+
limits=httpx.Limits(max_connections=None, max_keepalive_connections=20),
|
|
429
|
+
)
|
|
430
|
+
self._http = httpx.AsyncClient(transport=transport, follow_redirects=False, trust_env=False)
|
|
431
|
+
return self._http
|
|
432
|
+
|
|
433
|
+
async def _post(self, endpoint: str, payload: bytes, key: str, timeout: float) -> tuple[int, dict[str, str], bytes]:
|
|
434
|
+
reused = True
|
|
435
|
+
|
|
436
|
+
async def trace(event_name: str, info: dict[str, Any]) -> None:
|
|
437
|
+
nonlocal reused
|
|
438
|
+
if event_name in ("connection.connect_tcp.started", "connection.connect_unix_socket.started"):
|
|
439
|
+
reused = False
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
async with self._connection().stream(
|
|
443
|
+
"POST",
|
|
444
|
+
f"{self.base_url}/{quote(endpoint)}",
|
|
445
|
+
content=payload,
|
|
446
|
+
headers=self._request_headers(key),
|
|
447
|
+
timeout=timeout,
|
|
448
|
+
extensions={"trace": trace},
|
|
449
|
+
) as response:
|
|
450
|
+
# Like the sync transport, request identity encoding and pass
|
|
451
|
+
# the actual response bytes to our shared JSON/error decoder.
|
|
452
|
+
raw = b"".join([part async for part in response.aiter_raw()])
|
|
453
|
+
return response.status_code, dict(response.headers), raw
|
|
454
|
+
except httpx.TransportError as cause:
|
|
455
|
+
raise _TransportFailure(cause, reused) from cause
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _is_disconnect(cause: BaseException) -> bool:
|
|
459
|
+
"""HTTPX's wrappers around the sync client's refreshable disconnects."""
|
|
460
|
+
seen: set[int] = set()
|
|
461
|
+
pending = [cause]
|
|
462
|
+
while pending:
|
|
463
|
+
current = pending.pop()
|
|
464
|
+
if id(current) in seen:
|
|
465
|
+
continue
|
|
466
|
+
seen.add(id(current))
|
|
467
|
+
if isinstance(current, _DISCONNECTS):
|
|
468
|
+
return True
|
|
469
|
+
if isinstance(current, httpx.RemoteProtocolError) and str(current) == "Server disconnected without sending a response.":
|
|
470
|
+
return True
|
|
471
|
+
pending.extend(error for error in (current.__cause__, current.__context__) if error is not None)
|
|
472
|
+
return False
|