morphiaone 0.1.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.
- morphiaone/__init__.py +91 -0
- morphiaone/client.py +499 -0
- morphiaone/errors.py +168 -0
- morphiaone/models.py +22 -0
- morphiaone/py.typed +1 -0
- morphiaone/v1.py +1323 -0
- morphiaone-0.1.0.dist-info/METADATA +292 -0
- morphiaone-0.1.0.dist-info/RECORD +11 -0
- morphiaone-0.1.0.dist-info/WHEEL +5 -0
- morphiaone-0.1.0.dist-info/licenses/LICENSE +21 -0
- morphiaone-0.1.0.dist-info/top_level.txt +1 -0
morphiaone/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""MorphiaOne SDK (Developer Preview): the V1 API client, its errors and retry policy.
|
|
2
|
+
|
|
3
|
+
from morphiaone import MorphiaOne
|
|
4
|
+
|
|
5
|
+
client = MorphiaOne(api_key=..., base_url=...)
|
|
6
|
+
asset = client.assets.upload("face.jpg")
|
|
7
|
+
job = client.skin.analyze(asset.id, ["acne"], wait=True)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import RetryPolicy
|
|
11
|
+
from .errors import (
|
|
12
|
+
ApiError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
DeadlineExceeded,
|
|
16
|
+
InsufficientCreditsError,
|
|
17
|
+
InsufficientScopeError,
|
|
18
|
+
MorphiaOneError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PayloadTooLargeError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
ProcessingError,
|
|
23
|
+
ProtocolError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ServerError,
|
|
26
|
+
ServiceUnavailableError,
|
|
27
|
+
TransportError,
|
|
28
|
+
UnsupportedFeatureError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .models import JobError, JobStatus
|
|
32
|
+
from .v1 import (
|
|
33
|
+
Asset,
|
|
34
|
+
AssetContent,
|
|
35
|
+
AsyncMorphiaOne,
|
|
36
|
+
HairAppliedColorGradient,
|
|
37
|
+
HairAppliedColorStop,
|
|
38
|
+
HairTryOnApplied,
|
|
39
|
+
HairTryOnOutput,
|
|
40
|
+
Job,
|
|
41
|
+
JobInputRef,
|
|
42
|
+
JobOutput,
|
|
43
|
+
MakeupTryOnOutput,
|
|
44
|
+
MorphiaOne,
|
|
45
|
+
ResultAssetRef,
|
|
46
|
+
SkinAnalysisOutput,
|
|
47
|
+
SkinConcernResult,
|
|
48
|
+
UnknownJobOutput,
|
|
49
|
+
UsageReport,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ApiError",
|
|
54
|
+
"Asset",
|
|
55
|
+
"AssetContent",
|
|
56
|
+
"AsyncMorphiaOne",
|
|
57
|
+
"AuthenticationError",
|
|
58
|
+
"ConflictError",
|
|
59
|
+
"DeadlineExceeded",
|
|
60
|
+
"HairAppliedColorGradient",
|
|
61
|
+
"HairAppliedColorStop",
|
|
62
|
+
"HairTryOnApplied",
|
|
63
|
+
"HairTryOnOutput",
|
|
64
|
+
"InsufficientCreditsError",
|
|
65
|
+
"InsufficientScopeError",
|
|
66
|
+
"Job",
|
|
67
|
+
"JobError",
|
|
68
|
+
"JobInputRef",
|
|
69
|
+
"JobOutput",
|
|
70
|
+
"JobStatus",
|
|
71
|
+
"MakeupTryOnOutput",
|
|
72
|
+
"MorphiaOne",
|
|
73
|
+
"MorphiaOneError",
|
|
74
|
+
"NotFoundError",
|
|
75
|
+
"PayloadTooLargeError",
|
|
76
|
+
"PermissionDeniedError",
|
|
77
|
+
"ProcessingError",
|
|
78
|
+
"ProtocolError",
|
|
79
|
+
"RateLimitError",
|
|
80
|
+
"ResultAssetRef",
|
|
81
|
+
"RetryPolicy",
|
|
82
|
+
"ServerError",
|
|
83
|
+
"ServiceUnavailableError",
|
|
84
|
+
"SkinAnalysisOutput",
|
|
85
|
+
"SkinConcernResult",
|
|
86
|
+
"TransportError",
|
|
87
|
+
"UnknownJobOutput",
|
|
88
|
+
"UnsupportedFeatureError",
|
|
89
|
+
"UsageReport",
|
|
90
|
+
"ValidationError",
|
|
91
|
+
]
|
morphiaone/client.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""The transport MorphiaOne and AsyncMorphiaOne share, and nothing else.
|
|
2
|
+
|
|
3
|
+
The legacy public API this module once wrapped is retired; what stays is the request
|
|
4
|
+
loop, the retry policy and the error table, which `v1.py` borrows through `_transport`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from email.utils import parsedate_to_datetime
|
|
17
|
+
from typing import Self
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import (
|
|
23
|
+
ApiError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
DeadlineExceeded,
|
|
27
|
+
InsufficientCreditsError,
|
|
28
|
+
InsufficientScopeError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
PayloadTooLargeError,
|
|
31
|
+
PermissionDeniedError,
|
|
32
|
+
ProtocolError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
ServerError,
|
|
35
|
+
ServiceUnavailableError,
|
|
36
|
+
TransportError,
|
|
37
|
+
UnsupportedFeatureError,
|
|
38
|
+
ValidationError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: The same set as the TypeScript client. The contract marks 429, 500 and 503
|
|
42
|
+
#: retryable (`scripts/developer_docs.py` STATUS_MEANINGS); 408, 502 and 504 are a
|
|
43
|
+
#: proxy's, never the API's, and a retry of them resends the same Idempotency-Key.
|
|
44
|
+
#: 425 is not here: nothing in the contract answers it.
|
|
45
|
+
_RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
|
|
46
|
+
#: Refusals a blind retry cannot cure (staff Gate F, B3): a daily quota resets on its
|
|
47
|
+
#: own clock and a credit balance only moves when someone tops it up, so the SDK
|
|
48
|
+
#: raises them at once, whatever `Retry-After` says.
|
|
49
|
+
_NEVER_AUTO_RETRIED_CODES = frozenset({"QUOTA_EXCEEDED", "INSUFFICIENT_CREDITS"})
|
|
50
|
+
#: A 429 the SDK retries only when the server says when: the account's own queue is
|
|
51
|
+
#: full, and without a `Retry-After` there is no interval worth waiting.
|
|
52
|
+
_RETRIED_ONLY_WITH_RETRY_AFTER = frozenset({"TENANT_QUEUE_LIMIT_EXCEEDED"})
|
|
53
|
+
#: A server-named ``Retry-After`` is honoured as named, not shortened to ``max_delay``:
|
|
54
|
+
#: a retry sent before the server said it would answer is refused again. Above this
|
|
55
|
+
#: ceiling, or when the caller's deadline cannot hold the wait, the SDK raises the error
|
|
56
|
+
#: (with ``retry_after`` set) instead of retrying early. Same values as TypeScript.
|
|
57
|
+
RETRY_AFTER_CEILING = 60.0
|
|
58
|
+
#: The poll interval when the server names none, and the floor under any it names, so a
|
|
59
|
+
#: ``Retry-After: 0`` or ``max_delay=0`` never becomes a tight poll loop.
|
|
60
|
+
_DEFAULT_POLL_INTERVAL = 1.0
|
|
61
|
+
MIN_POLL_INTERVAL = 0.25
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RetryPolicy:
|
|
66
|
+
max_attempts: int = 4
|
|
67
|
+
base_delay: float = 0.25
|
|
68
|
+
max_delay: float = 5.0
|
|
69
|
+
jitter_ratio: float = 0.2
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
if self.max_attempts < 1:
|
|
73
|
+
raise ValueError("max_attempts must be positive")
|
|
74
|
+
if self.base_delay < 0 or self.max_delay < 0:
|
|
75
|
+
raise ValueError("retry delays cannot be negative")
|
|
76
|
+
if self.base_delay > self.max_delay:
|
|
77
|
+
raise ValueError("base_delay cannot exceed max_delay")
|
|
78
|
+
if not 0 <= self.jitter_ratio <= 1:
|
|
79
|
+
raise ValueError("jitter_ratio must be between 0 and 1")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Transport:
|
|
83
|
+
"""One credentialed HTTP client with bounded, jittered retries."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
api_key: str,
|
|
89
|
+
base_url: str,
|
|
90
|
+
request_timeout: float = 10.0,
|
|
91
|
+
operation_timeout: float = 120.0,
|
|
92
|
+
max_image_bytes: int = 10 * 1024 * 1024,
|
|
93
|
+
retry_policy: RetryPolicy | None = None,
|
|
94
|
+
http_client: httpx.Client | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
_validate_client_options(
|
|
97
|
+
api_key, base_url, request_timeout, operation_timeout, max_image_bytes
|
|
98
|
+
)
|
|
99
|
+
self._api_key = api_key
|
|
100
|
+
self._base_url = base_url.rstrip("/")
|
|
101
|
+
self._request_timeout = request_timeout
|
|
102
|
+
self._operation_timeout = operation_timeout
|
|
103
|
+
self._max_image_bytes = max_image_bytes
|
|
104
|
+
self._retry_policy = retry_policy or RetryPolicy()
|
|
105
|
+
self._client = http_client or httpx.Client()
|
|
106
|
+
self._owns_client = http_client is None
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> Self:
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *_: object) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
if self._owns_client:
|
|
116
|
+
self._client.close()
|
|
117
|
+
|
|
118
|
+
def _request(
|
|
119
|
+
self,
|
|
120
|
+
send: Callable[[float], httpx.Response],
|
|
121
|
+
deadline: float,
|
|
122
|
+
*,
|
|
123
|
+
expected: set[int],
|
|
124
|
+
) -> httpx.Response:
|
|
125
|
+
last_transport_error: httpx.TransportError | None = None
|
|
126
|
+
for attempt in range(self._retry_policy.max_attempts):
|
|
127
|
+
remaining = deadline - time.monotonic()
|
|
128
|
+
if remaining <= 0:
|
|
129
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
130
|
+
try:
|
|
131
|
+
response = send(min(self._request_timeout, remaining))
|
|
132
|
+
except httpx.TransportError as error:
|
|
133
|
+
last_transport_error = error
|
|
134
|
+
if attempt + 1 >= self._retry_policy.max_attempts:
|
|
135
|
+
break
|
|
136
|
+
self._backoff(attempt, deadline)
|
|
137
|
+
continue
|
|
138
|
+
if response.status_code in expected:
|
|
139
|
+
return response
|
|
140
|
+
_refuse_redirect(response)
|
|
141
|
+
last = attempt + 1 >= self._retry_policy.max_attempts
|
|
142
|
+
if not last and _retryable_status(response):
|
|
143
|
+
retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
|
|
144
|
+
if retry_after is None:
|
|
145
|
+
self._backoff(attempt, deadline)
|
|
146
|
+
continue
|
|
147
|
+
if _fits_server_wait(retry_after, deadline, self._retry_policy):
|
|
148
|
+
time.sleep(_server_delay(retry_after, self._retry_policy))
|
|
149
|
+
continue
|
|
150
|
+
raise _parse_api_error(response)
|
|
151
|
+
api_error = _parse_api_error(response)
|
|
152
|
+
retryable = (
|
|
153
|
+
response.status_code == 409
|
|
154
|
+
and api_error.code == "IDEMPOTENCY_IN_PROGRESS"
|
|
155
|
+
)
|
|
156
|
+
if not retryable or last:
|
|
157
|
+
raise api_error
|
|
158
|
+
if api_error.retry_after is None:
|
|
159
|
+
self._backoff(attempt, deadline)
|
|
160
|
+
elif _fits_server_wait(api_error.retry_after, deadline, self._retry_policy):
|
|
161
|
+
time.sleep(_server_delay(api_error.retry_after, self._retry_policy))
|
|
162
|
+
else:
|
|
163
|
+
raise api_error
|
|
164
|
+
if last_transport_error is not None:
|
|
165
|
+
raise TransportError(
|
|
166
|
+
"request failed after bounded retries"
|
|
167
|
+
) from last_transport_error
|
|
168
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
169
|
+
|
|
170
|
+
def _backoff(self, attempt: int, deadline: float) -> None:
|
|
171
|
+
base = self._retry_policy.base_delay * (2**attempt)
|
|
172
|
+
self._sleep(_jittered_delay(base, self._retry_policy), deadline)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _sleep(delay: float, deadline: float) -> None:
|
|
176
|
+
if delay >= deadline - time.monotonic():
|
|
177
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
178
|
+
time.sleep(delay)
|
|
179
|
+
|
|
180
|
+
def _url(self, path: str) -> str:
|
|
181
|
+
return f"{self._base_url}{path}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _AsyncTransport:
|
|
185
|
+
"""The asyncio twin of `_Transport`."""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
api_key: str,
|
|
191
|
+
base_url: str,
|
|
192
|
+
request_timeout: float = 10.0,
|
|
193
|
+
operation_timeout: float = 120.0,
|
|
194
|
+
max_image_bytes: int = 10 * 1024 * 1024,
|
|
195
|
+
retry_policy: RetryPolicy | None = None,
|
|
196
|
+
http_client: httpx.AsyncClient | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
_validate_client_options(
|
|
199
|
+
api_key, base_url, request_timeout, operation_timeout, max_image_bytes
|
|
200
|
+
)
|
|
201
|
+
self._api_key = api_key
|
|
202
|
+
self._base_url = base_url.rstrip("/")
|
|
203
|
+
self._request_timeout = request_timeout
|
|
204
|
+
self._operation_timeout = operation_timeout
|
|
205
|
+
self._max_image_bytes = max_image_bytes
|
|
206
|
+
self._retry_policy = retry_policy or RetryPolicy()
|
|
207
|
+
self._client = http_client or httpx.AsyncClient()
|
|
208
|
+
self._owns_client = http_client is None
|
|
209
|
+
|
|
210
|
+
async def __aenter__(self) -> Self:
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
async def __aexit__(self, *_: object) -> None:
|
|
214
|
+
await self.aclose()
|
|
215
|
+
|
|
216
|
+
async def aclose(self) -> None:
|
|
217
|
+
if self._owns_client:
|
|
218
|
+
await self._client.aclose()
|
|
219
|
+
|
|
220
|
+
async def _request(
|
|
221
|
+
self,
|
|
222
|
+
send: Callable[[float], Awaitable[httpx.Response]],
|
|
223
|
+
deadline: float,
|
|
224
|
+
*,
|
|
225
|
+
expected: set[int],
|
|
226
|
+
) -> httpx.Response:
|
|
227
|
+
last_transport_error: httpx.TransportError | None = None
|
|
228
|
+
for attempt in range(self._retry_policy.max_attempts):
|
|
229
|
+
remaining = deadline - time.monotonic()
|
|
230
|
+
if remaining <= 0:
|
|
231
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
232
|
+
try:
|
|
233
|
+
response = await send(min(self._request_timeout, remaining))
|
|
234
|
+
except httpx.TransportError as error:
|
|
235
|
+
last_transport_error = error
|
|
236
|
+
if attempt + 1 >= self._retry_policy.max_attempts:
|
|
237
|
+
break
|
|
238
|
+
await self._backoff(attempt, deadline)
|
|
239
|
+
continue
|
|
240
|
+
if response.status_code in expected:
|
|
241
|
+
return response
|
|
242
|
+
_refuse_redirect(response)
|
|
243
|
+
last = attempt + 1 >= self._retry_policy.max_attempts
|
|
244
|
+
if not last and _retryable_status(response):
|
|
245
|
+
retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
|
|
246
|
+
if retry_after is None:
|
|
247
|
+
await self._backoff(attempt, deadline)
|
|
248
|
+
continue
|
|
249
|
+
if _fits_server_wait(retry_after, deadline, self._retry_policy):
|
|
250
|
+
await asyncio.sleep(_server_delay(retry_after, self._retry_policy))
|
|
251
|
+
continue
|
|
252
|
+
raise _parse_api_error(response)
|
|
253
|
+
api_error = _parse_api_error(response)
|
|
254
|
+
retryable = (
|
|
255
|
+
response.status_code == 409
|
|
256
|
+
and api_error.code == "IDEMPOTENCY_IN_PROGRESS"
|
|
257
|
+
)
|
|
258
|
+
if not retryable or last:
|
|
259
|
+
raise api_error
|
|
260
|
+
if api_error.retry_after is None:
|
|
261
|
+
await self._backoff(attempt, deadline)
|
|
262
|
+
elif _fits_server_wait(api_error.retry_after, deadline, self._retry_policy):
|
|
263
|
+
await asyncio.sleep(
|
|
264
|
+
_server_delay(api_error.retry_after, self._retry_policy)
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
raise api_error
|
|
268
|
+
if last_transport_error is not None:
|
|
269
|
+
raise TransportError(
|
|
270
|
+
"request failed after bounded retries"
|
|
271
|
+
) from last_transport_error
|
|
272
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
273
|
+
|
|
274
|
+
async def _backoff(self, attempt: int, deadline: float) -> None:
|
|
275
|
+
base = self._retry_policy.base_delay * (2**attempt)
|
|
276
|
+
await self._sleep(_jittered_delay(base, self._retry_policy), deadline)
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
async def _sleep(delay: float, deadline: float) -> None:
|
|
280
|
+
if delay >= deadline - time.monotonic():
|
|
281
|
+
raise DeadlineExceeded("operation deadline exceeded")
|
|
282
|
+
await asyncio.sleep(delay)
|
|
283
|
+
|
|
284
|
+
def _url(self, path: str) -> str:
|
|
285
|
+
return f"{self._base_url}{path}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _validate_client_options(
|
|
289
|
+
api_key: str,
|
|
290
|
+
base_url: str,
|
|
291
|
+
request_timeout: float,
|
|
292
|
+
operation_timeout: float,
|
|
293
|
+
max_image_bytes: int,
|
|
294
|
+
) -> None:
|
|
295
|
+
parsed = urlsplit(base_url)
|
|
296
|
+
if (
|
|
297
|
+
parsed.scheme not in {"http", "https"}
|
|
298
|
+
or not parsed.netloc
|
|
299
|
+
or parsed.username is not None
|
|
300
|
+
or parsed.password is not None
|
|
301
|
+
or parsed.query
|
|
302
|
+
or parsed.fragment
|
|
303
|
+
):
|
|
304
|
+
raise ValueError("base_url must be an absolute HTTP(S) URL")
|
|
305
|
+
if not api_key:
|
|
306
|
+
raise ValueError("api_key cannot be empty")
|
|
307
|
+
if request_timeout <= 0 or operation_timeout <= 0:
|
|
308
|
+
raise ValueError("timeouts must be positive")
|
|
309
|
+
if operation_timeout < request_timeout:
|
|
310
|
+
raise ValueError("operation_timeout must be at least request_timeout")
|
|
311
|
+
if max_image_bytes < 1:
|
|
312
|
+
raise ValueError("max_image_bytes must be positive")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _deadline(timeout: float | None, default: float) -> float:
|
|
316
|
+
duration = default if timeout is None else timeout
|
|
317
|
+
if duration <= 0:
|
|
318
|
+
raise ValueError("timeout must be positive")
|
|
319
|
+
return time.monotonic() + duration
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _jittered_delay(delay: float, policy: RetryPolicy) -> float:
|
|
323
|
+
"""The client's own backoff, when the server named no ``Retry-After``.
|
|
324
|
+
|
|
325
|
+
Capped at ``max_delay``, positive jitter only. Same formula as TypeScript.
|
|
326
|
+
"""
|
|
327
|
+
bounded = min(max(0.0, delay), policy.max_delay)
|
|
328
|
+
return min(
|
|
329
|
+
policy.max_delay,
|
|
330
|
+
bounded + random.uniform(0, bounded * policy.jitter_ratio),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _server_delay(retry_after: float, policy: RetryPolicy) -> float:
|
|
335
|
+
"""A server-named wait: never shorter than it, jitter no larger than ``max_delay``."""
|
|
336
|
+
base = max(0.0, retry_after)
|
|
337
|
+
return base + random.uniform(0, min(base * policy.jitter_ratio, policy.max_delay))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _fits_server_wait(retry_after: float, deadline: float, policy: RetryPolicy) -> bool:
|
|
341
|
+
"""Whether a server-named wait may be slept before the retry.
|
|
342
|
+
|
|
343
|
+
At most ``RETRY_AFTER_CEILING``, and short enough (jitter included) that the retry
|
|
344
|
+
still lands before the caller's deadline. Otherwise the caller raises the error.
|
|
345
|
+
"""
|
|
346
|
+
if retry_after > RETRY_AFTER_CEILING:
|
|
347
|
+
return False
|
|
348
|
+
worst = retry_after + min(retry_after * policy.jitter_ratio, policy.max_delay)
|
|
349
|
+
return worst < deadline - time.monotonic()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _poll_delay(retry_after: float | None, policy: RetryPolicy) -> float:
|
|
353
|
+
"""The wait between two polls of an unsettled job.
|
|
354
|
+
|
|
355
|
+
The server's ``Retry-After``, floored at ``MIN_POLL_INTERVAL`` and held under
|
|
356
|
+
``RETRY_AFTER_CEILING``; 1 s when it names none. Independent of ``max_delay``,
|
|
357
|
+
which bounds the client's own retry backoff only.
|
|
358
|
+
"""
|
|
359
|
+
named = _DEFAULT_POLL_INTERVAL if retry_after is None else retry_after
|
|
360
|
+
base = min(max(named, MIN_POLL_INTERVAL), RETRY_AFTER_CEILING)
|
|
361
|
+
return _server_delay(base, policy)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _refuse_redirect(response: httpx.Response) -> None:
|
|
365
|
+
"""A redirect answer to a credentialed request is a fault, never a hop.
|
|
366
|
+
|
|
367
|
+
Every request this client sends carries the API key, and httpx strips only
|
|
368
|
+
``Authorization`` when it follows a redirect to another origin -- ``X-API-Key``
|
|
369
|
+
would travel. So every call passes ``follow_redirects=False`` (an injected
|
|
370
|
+
``http_client`` may have turned following on) and the answer is refused here,
|
|
371
|
+
before the error parser reads it as an unrelated envelope fault.
|
|
372
|
+
"""
|
|
373
|
+
if 300 <= response.status_code < 400:
|
|
374
|
+
raise ProtocolError(
|
|
375
|
+
f"server answered HTTP {response.status_code} with a redirect; "
|
|
376
|
+
"a credentialed request is not followed"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# One status, one class. Ordered widest-last so the specific entries win, and written
|
|
381
|
+
# as a table rather than a chain because the table is what the contract-alignment test
|
|
382
|
+
# reads: a status the runtime answers and this table does not name fails there.
|
|
383
|
+
_ERROR_TYPES: dict[int, type[ApiError]] = {
|
|
384
|
+
400: ValidationError,
|
|
385
|
+
401: AuthenticationError,
|
|
386
|
+
402: InsufficientCreditsError,
|
|
387
|
+
403: PermissionDeniedError,
|
|
388
|
+
404: NotFoundError,
|
|
389
|
+
409: ConflictError,
|
|
390
|
+
410: NotFoundError,
|
|
391
|
+
413: PayloadTooLargeError,
|
|
392
|
+
415: ValidationError,
|
|
393
|
+
422: ValidationError,
|
|
394
|
+
429: RateLimitError,
|
|
395
|
+
501: UnsupportedFeatureError,
|
|
396
|
+
503: ServiceUnavailableError,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# One code, one class, consulted before the status table. This is the narrow half of
|
|
401
|
+
# the mapping and it stays narrow: an entry belongs here only when a code's remedy
|
|
402
|
+
# differs from what its status alone implies. `INSUFFICIENT_SCOPE` qualifies -- the
|
|
403
|
+
# other three codes the gateway answers 403 for are a disabled tenant, a disabled
|
|
404
|
+
# account and an unrecognized key, and none of those is a scope problem.
|
|
405
|
+
_ERROR_CODES: dict[str, type[ApiError]] = {
|
|
406
|
+
"INSUFFICIENT_SCOPE": InsufficientScopeError,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _error_type(status_code: int, code: str = "") -> type[ApiError]:
|
|
411
|
+
"""The class for one status, refined by code where the code says more.
|
|
412
|
+
|
|
413
|
+
The code is checked first because `error.code` is the machine contract and the
|
|
414
|
+
status is the coarser reading of it. Every class the code table can return is a
|
|
415
|
+
subclass of the one the status table returns for the same response, so a caller
|
|
416
|
+
who catches by status keeps catching it either way.
|
|
417
|
+
|
|
418
|
+
An unlisted 5xx -- other than 501/503 -- is `ServerError`, and any other unlisted
|
|
419
|
+
status `ApiError`, with every response field filled in, so a status this SDK does
|
|
420
|
+
not know yet is readable rather than a parsing failure.
|
|
421
|
+
"""
|
|
422
|
+
refined = _ERROR_CODES.get(code)
|
|
423
|
+
if refined is not None:
|
|
424
|
+
return refined
|
|
425
|
+
named = _ERROR_TYPES.get(status_code)
|
|
426
|
+
if named is not None:
|
|
427
|
+
return named
|
|
428
|
+
return ServerError if status_code >= 500 else ApiError
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _retryable_status(response: httpx.Response) -> bool:
|
|
432
|
+
"""Whether a refusal is retried by its status, before its error is raised.
|
|
433
|
+
|
|
434
|
+
The three 429 codes are distinct (B3): `RATE_LIMITED` is retried, honouring
|
|
435
|
+
`Retry-After`; `TENANT_QUEUE_LIMIT_EXCEEDED` is retried only with a `Retry-After`;
|
|
436
|
+
`QUOTA_EXCEEDED` is never retried blindly. A 429 whose body names no code is
|
|
437
|
+
retried as a rate limit.
|
|
438
|
+
"""
|
|
439
|
+
if response.status_code not in _RETRYABLE_STATUS_CODES:
|
|
440
|
+
return False
|
|
441
|
+
if response.status_code != 429:
|
|
442
|
+
return True
|
|
443
|
+
try:
|
|
444
|
+
code = response.json()["error"]["code"]
|
|
445
|
+
except (KeyError, TypeError, ValueError):
|
|
446
|
+
return True
|
|
447
|
+
if code in _NEVER_AUTO_RETRIED_CODES:
|
|
448
|
+
return False
|
|
449
|
+
if code in _RETRIED_ONLY_WITH_RETRY_AFTER:
|
|
450
|
+
return _retry_after_seconds(response.headers.get("Retry-After")) is not None
|
|
451
|
+
return True
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _parse_api_error(response: httpx.Response) -> ApiError:
|
|
455
|
+
try:
|
|
456
|
+
payload = response.json()["error"]
|
|
457
|
+
code = payload["code"]
|
|
458
|
+
message = payload["message"]
|
|
459
|
+
details = payload.get("details", {})
|
|
460
|
+
request_id = payload.get("request_id")
|
|
461
|
+
if request_id is None:
|
|
462
|
+
request_id = response.headers.get("X-Request-Id")
|
|
463
|
+
if not isinstance(code, str) or not isinstance(message, str):
|
|
464
|
+
raise TypeError
|
|
465
|
+
if not isinstance(details, (dict, list)):
|
|
466
|
+
raise TypeError
|
|
467
|
+
if request_id is not None and not isinstance(request_id, str):
|
|
468
|
+
raise TypeError
|
|
469
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
470
|
+
raise ProtocolError(
|
|
471
|
+
f"server returned an invalid error envelope for HTTP {response.status_code}"
|
|
472
|
+
) from error
|
|
473
|
+
retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
|
|
474
|
+
error_type = _error_type(response.status_code, code)
|
|
475
|
+
return error_type(
|
|
476
|
+
message,
|
|
477
|
+
status=response.status_code,
|
|
478
|
+
code=code,
|
|
479
|
+
details=details,
|
|
480
|
+
request_id=request_id,
|
|
481
|
+
retry_after=retry_after,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _retry_after_seconds(value: str | None) -> float | None:
|
|
486
|
+
if value is None:
|
|
487
|
+
return None
|
|
488
|
+
normalized = value.strip()
|
|
489
|
+
if normalized.isdigit():
|
|
490
|
+
return float(normalized)
|
|
491
|
+
try:
|
|
492
|
+
retry_at = parsedate_to_datetime(normalized)
|
|
493
|
+
except (TypeError, ValueError, OverflowError):
|
|
494
|
+
return None
|
|
495
|
+
if retry_at is None:
|
|
496
|
+
return None
|
|
497
|
+
if retry_at.tzinfo is None:
|
|
498
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
499
|
+
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
|