timbro-protocol 0.10.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.
- timbro_protocol/__init__.py +45 -0
- timbro_protocol/_client.py +343 -0
- timbro_protocol/_core.py +565 -0
- timbro_protocol/_errors.py +302 -0
- timbro_protocol/_headers.py +75 -0
- timbro_protocol/_operation.py +41 -0
- timbro_protocol/_pagination.py +63 -0
- timbro_protocol/_version.py +1 -0
- timbro_protocol/_webhooks.py +219 -0
- timbro_protocol/py.typed +0 -0
- timbro_protocol/runtime.py +56 -0
- timbro_protocol-0.10.0.dist-info/METADATA +37 -0
- timbro_protocol-0.10.0.dist-info/RECORD +16 -0
- timbro_protocol-0.10.0.dist-info/WHEEL +5 -0
- timbro_protocol-0.10.0.dist-info/licenses/LICENSE +202 -0
- timbro_protocol-0.10.0.dist-info/top_level.txt +1 -0
timbro_protocol/_core.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Request building, response decoding, error mapping, and retry decisions.
|
|
2
|
+
|
|
3
|
+
Nothing here performs I/O, so the sync and async executors share every rule.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import email.utils
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._errors import (
|
|
23
|
+
RETRYABLE_STATUSES,
|
|
24
|
+
APIConnectionError,
|
|
25
|
+
APIError,
|
|
26
|
+
APITimeoutError,
|
|
27
|
+
ProblemDetail,
|
|
28
|
+
TimbroError,
|
|
29
|
+
UnexpectedResponseError,
|
|
30
|
+
api_error_class,
|
|
31
|
+
)
|
|
32
|
+
from ._operation import RAW_REQUEST, Operation
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE_URL = "https://api.timbro.do"
|
|
35
|
+
DEFAULT_TIMEOUT = 30.0
|
|
36
|
+
DEFAULT_MAX_RETRIES = 2
|
|
37
|
+
MAX_RETRY_DELAY_SECONDS = 2.0
|
|
38
|
+
|
|
39
|
+
LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
40
|
+
PATH_PARAMETER = re.compile(r"\{([^{}]+)\}")
|
|
41
|
+
# Matched whole, with ASCII digits only, as the JavaScript SDKs do.
|
|
42
|
+
PRINTABLE_ASCII = re.compile(r"[\x20-\x7e]+")
|
|
43
|
+
DELTA_SECONDS = re.compile(r"[0-9]+")
|
|
44
|
+
UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
45
|
+
IN_PROGRESS_CODE = "idempotency_request_in_progress"
|
|
46
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
47
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
48
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
49
|
+
|
|
50
|
+
# Failures raised before any request byte left this process.
|
|
51
|
+
NOT_SENT_ERRORS = (
|
|
52
|
+
httpx.ConnectError,
|
|
53
|
+
httpx.ConnectTimeout,
|
|
54
|
+
httpx.PoolTimeout,
|
|
55
|
+
httpx.ProxyError,
|
|
56
|
+
httpx.UnsupportedProtocol,
|
|
57
|
+
httpx.LocalProtocolError,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger("timbro_protocol")
|
|
61
|
+
|
|
62
|
+
TimeoutTypes = float | httpx.Timeout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FileResponse:
|
|
67
|
+
"""The bytes of a successful download, before any check of its declared metadata.
|
|
68
|
+
|
|
69
|
+
`media_type` is the `Content-Type` without parameters, and `headers` holds every
|
|
70
|
+
response header under its lowercase name.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
content: bytes = field(repr=False)
|
|
74
|
+
media_type: str
|
|
75
|
+
headers: Mapping[str, str] = field(repr=False)
|
|
76
|
+
request_id: str | None
|
|
77
|
+
operation: Operation
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class ClientSettings:
|
|
82
|
+
"""Validated client configuration shared by the sync and async executors."""
|
|
83
|
+
|
|
84
|
+
api_key: str = field(repr=False)
|
|
85
|
+
base_url: str
|
|
86
|
+
timeout: httpx.Timeout
|
|
87
|
+
max_retries: int
|
|
88
|
+
user_agent: str
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def create(
|
|
92
|
+
cls,
|
|
93
|
+
*,
|
|
94
|
+
api_key: str,
|
|
95
|
+
base_url: str,
|
|
96
|
+
timeout: TimeoutTypes,
|
|
97
|
+
max_retries: int,
|
|
98
|
+
user_agent: str,
|
|
99
|
+
) -> ClientSettings:
|
|
100
|
+
require_bearer_credential(api_key, "api_key")
|
|
101
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
|
|
102
|
+
raise ValueError("max_retries must be a non-negative integer.")
|
|
103
|
+
return cls(
|
|
104
|
+
api_key=api_key,
|
|
105
|
+
base_url=normalize_base_url(base_url),
|
|
106
|
+
timeout=as_timeout(timeout),
|
|
107
|
+
max_retries=max_retries,
|
|
108
|
+
user_agent=user_agent,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def require_bearer_credential(value: object, name: str) -> str:
|
|
113
|
+
if not isinstance(value, str) or value.strip() == "":
|
|
114
|
+
raise ValueError(f"{name} is required.")
|
|
115
|
+
# A credential that cannot be a header value would otherwise surface in an httpx error.
|
|
116
|
+
if not PRINTABLE_ASCII.fullmatch(value) or " " in value:
|
|
117
|
+
raise ValueError(f"{name} must be printable ASCII without spaces.")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_base_url(base_url: str) -> str:
|
|
122
|
+
"""Returns the origin Timbro is reached at, refusing anything but an HTTPS origin.
|
|
123
|
+
|
|
124
|
+
Plain HTTP is allowed only for a loopback host, for local development.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
parts = urlsplit(base_url)
|
|
128
|
+
port = parts.port
|
|
129
|
+
except ValueError as error:
|
|
130
|
+
raise ValueError("base_url must be an absolute https origin.") from error
|
|
131
|
+
host = parts.hostname
|
|
132
|
+
if (
|
|
133
|
+
host is None
|
|
134
|
+
or parts.username is not None
|
|
135
|
+
or parts.password is not None
|
|
136
|
+
or parts.path not in ("", "/")
|
|
137
|
+
or parts.query != ""
|
|
138
|
+
or parts.fragment != ""
|
|
139
|
+
or base_url.endswith(("?", "#"))
|
|
140
|
+
):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"base_url must be an origin with no path, query, fragment, or credentials."
|
|
143
|
+
)
|
|
144
|
+
if parts.scheme == "https" or (parts.scheme == "http" and host in LOCAL_HOSTS):
|
|
145
|
+
authority = f"[{host}]" if ":" in host else host
|
|
146
|
+
return f"{parts.scheme}://{authority}" + ("" if port is None else f":{port}")
|
|
147
|
+
raise ValueError("base_url must use https; http is allowed only for localhost.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def as_timeout(timeout: TimeoutTypes) -> httpx.Timeout:
|
|
151
|
+
if isinstance(timeout, httpx.Timeout):
|
|
152
|
+
return timeout
|
|
153
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
154
|
+
raise ValueError("timeout must be a positive number of seconds or an httpx.Timeout.")
|
|
155
|
+
return httpx.Timeout(float(timeout))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
KeyMode = Literal["test", "live"] | None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def key_mode(api_key: str) -> KeyMode:
|
|
162
|
+
"""The mode a `sk_test_` or `sk_live_` key works in, or None for any other key form."""
|
|
163
|
+
if api_key.startswith("sk_test_"):
|
|
164
|
+
return "test"
|
|
165
|
+
if api_key.startswith("sk_live_"):
|
|
166
|
+
return "live"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_user_agent(package: str, version: str, application: str | None = None) -> str:
|
|
171
|
+
"""Returns `timbro-<package>-python/<version> python/<x.y> httpx/<version> [application]`."""
|
|
172
|
+
python = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
173
|
+
agent = f"timbro-{package}-python/{version} python/{python} httpx/{httpx.__version__}"
|
|
174
|
+
if application is None:
|
|
175
|
+
return agent
|
|
176
|
+
if not PRINTABLE_ASCII.fullmatch(application) or application.strip() != application:
|
|
177
|
+
raise ValueError("application must be printable ASCII without surrounding spaces.")
|
|
178
|
+
return f"{agent} {application}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_request(
|
|
182
|
+
settings: ClientSettings,
|
|
183
|
+
operation: Operation,
|
|
184
|
+
*,
|
|
185
|
+
path_params: Mapping[str, str],
|
|
186
|
+
query: Mapping[str, object] | None,
|
|
187
|
+
body: Mapping[str, Any] | None,
|
|
188
|
+
idempotency_key: str | None,
|
|
189
|
+
timeout: httpx.Timeout | None,
|
|
190
|
+
access_token: str | None = None,
|
|
191
|
+
accept: str = JSON_MEDIA_TYPE,
|
|
192
|
+
) -> httpx.Request:
|
|
193
|
+
"""Builds the request; `timeout` is attached only when given, so a caller's client keeps its own.
|
|
194
|
+
|
|
195
|
+
`access_token` replaces the API key for an operation authorized by a grant.
|
|
196
|
+
"""
|
|
197
|
+
credential = (
|
|
198
|
+
settings.api_key
|
|
199
|
+
if access_token is None
|
|
200
|
+
else require_bearer_credential(access_token, "access_token")
|
|
201
|
+
)
|
|
202
|
+
headers = {
|
|
203
|
+
"Authorization": f"Bearer {credential}",
|
|
204
|
+
"Accept": accept,
|
|
205
|
+
"User-Agent": settings.user_agent,
|
|
206
|
+
}
|
|
207
|
+
content: bytes | None = None
|
|
208
|
+
if body is not None:
|
|
209
|
+
headers["Content-Type"] = JSON_MEDIA_TYPE
|
|
210
|
+
content = json.dumps(
|
|
211
|
+
body, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
212
|
+
).encode("utf-8")
|
|
213
|
+
if idempotency_key is not None:
|
|
214
|
+
headers["Idempotency-Key"] = require_idempotency_key(idempotency_key)
|
|
215
|
+
params = [
|
|
216
|
+
(name, query_value(item))
|
|
217
|
+
for name, value in (query or {}).items()
|
|
218
|
+
if value is not None
|
|
219
|
+
for item in (value if isinstance(value, (list, tuple)) else [value])
|
|
220
|
+
]
|
|
221
|
+
return httpx.Request(
|
|
222
|
+
operation.method,
|
|
223
|
+
settings.base_url + expand_path(operation.path, path_params),
|
|
224
|
+
params=tuple(params),
|
|
225
|
+
headers=headers,
|
|
226
|
+
content=content,
|
|
227
|
+
extensions={} if timeout is None else {"timeout": timeout.as_dict()},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
RAW_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
|
232
|
+
# A /v1/ path of URL path characters only: no scheme, host, query, fragment, or template.
|
|
233
|
+
RAW_PATH = re.compile(r"/v1/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def raw_operation(method: str, path: str) -> Operation:
|
|
237
|
+
"""The operation for a request to a route outside the published contract.
|
|
238
|
+
|
|
239
|
+
A `GET` is retried like any read; another method is retried only with an
|
|
240
|
+
idempotency key.
|
|
241
|
+
"""
|
|
242
|
+
verb = method.upper() if isinstance(method, str) else ""
|
|
243
|
+
if verb not in RAW_METHODS:
|
|
244
|
+
raise ValueError(f"method must be one of {', '.join(sorted(RAW_METHODS))}.")
|
|
245
|
+
segments = path.split("/")[2:] if isinstance(path, str) else []
|
|
246
|
+
if (
|
|
247
|
+
not isinstance(path, str)
|
|
248
|
+
or RAW_PATH.fullmatch(path) is None
|
|
249
|
+
or any(unquote(segment) in ("", ".", "..") for segment in segments)
|
|
250
|
+
):
|
|
251
|
+
raise ValueError("path must be a /v1/ path without a query, fragment, or empty segment.")
|
|
252
|
+
return Operation(
|
|
253
|
+
operation_id=f"{verb} {path}",
|
|
254
|
+
method=verb,
|
|
255
|
+
path=path,
|
|
256
|
+
retry="safe_to_retry" if verb == "GET" else "retry_with_same_idempotency_key",
|
|
257
|
+
idempotency="optional",
|
|
258
|
+
recovery=None,
|
|
259
|
+
name=RAW_REQUEST,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def expand_path(template: str, path_params: Mapping[str, str]) -> str:
|
|
264
|
+
def replace(match: re.Match[str]) -> str:
|
|
265
|
+
value = path_params.get(match.group(1))
|
|
266
|
+
# "." and ".." survive percent-encoding and would address a different path.
|
|
267
|
+
if not isinstance(value, str) or value in ("", ".", ".."):
|
|
268
|
+
raise ValueError(f"{match.group(1)} must be a non-empty identifier.")
|
|
269
|
+
return quote(value, safe="")
|
|
270
|
+
|
|
271
|
+
return PATH_PARAMETER.sub(replace, template)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def query_value(value: object) -> str:
|
|
275
|
+
if isinstance(value, bool):
|
|
276
|
+
return "true" if value else "false"
|
|
277
|
+
if isinstance(value, (str, int, float)):
|
|
278
|
+
return str(value)
|
|
279
|
+
raise TypeError(f"Unsupported query parameter value of type {type(value).__name__}.")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def require_idempotency_key(idempotency_key: str) -> str:
|
|
283
|
+
if (
|
|
284
|
+
not isinstance(idempotency_key, str)
|
|
285
|
+
or len(idempotency_key) > MAX_IDEMPOTENCY_KEY_LENGTH
|
|
286
|
+
or not PRINTABLE_ASCII.fullmatch(idempotency_key)
|
|
287
|
+
or idempotency_key.strip() == ""
|
|
288
|
+
):
|
|
289
|
+
raise ValueError("idempotency_key must be 1 to 255 printable ASCII characters.")
|
|
290
|
+
return idempotency_key
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def derive_idempotency_key(command: str, identity: str) -> str:
|
|
294
|
+
"""Returns `<command>_<hex sha256 of command, NUL, identity>`, as every Timbro SDK does."""
|
|
295
|
+
digest = hashlib.sha256(f"{command}\0{identity}".encode()).hexdigest()
|
|
296
|
+
return f"{command}_{digest}"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def may_retry(operation: Operation, request: httpx.Request) -> bool:
|
|
300
|
+
"""Retries a safe operation always, a keyed one only with its key, and nothing else."""
|
|
301
|
+
if operation.retry == "safe_to_retry":
|
|
302
|
+
return True
|
|
303
|
+
if operation.retry == "retry_with_same_idempotency_key":
|
|
304
|
+
return "Idempotency-Key" in request.headers
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def retry_delay_after_response(
|
|
309
|
+
operation: Operation,
|
|
310
|
+
request: httpx.Request,
|
|
311
|
+
response: httpx.Response,
|
|
312
|
+
retry: int,
|
|
313
|
+
max_retries: int,
|
|
314
|
+
) -> float | None:
|
|
315
|
+
"""Seconds to wait before retrying, or None when the response is final."""
|
|
316
|
+
if (
|
|
317
|
+
retry >= max_retries
|
|
318
|
+
or response.status_code not in RETRYABLE_STATUSES
|
|
319
|
+
or not may_retry(operation, request)
|
|
320
|
+
):
|
|
321
|
+
return None
|
|
322
|
+
retry_after = retry_after_seconds(response.headers.get("Retry-After"))
|
|
323
|
+
if retry_after is None:
|
|
324
|
+
return backoff_delay(retry)
|
|
325
|
+
return float(retry_after) if retry_after <= MAX_RETRY_DELAY_SECONDS else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def retry_delay_after_transport_error(
|
|
329
|
+
operation: Operation, request: httpx.Request, retry: int, max_retries: int
|
|
330
|
+
) -> float | None:
|
|
331
|
+
if retry >= max_retries or not may_retry(operation, request):
|
|
332
|
+
return None
|
|
333
|
+
return backoff_delay(retry)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def backoff_delay(retry: int) -> float:
|
|
337
|
+
return float(min(0.1 * 2**retry, MAX_RETRY_DELAY_SECONDS))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def retry_after_seconds(header: str | None, now: float | None = None) -> int | None:
|
|
341
|
+
"""Reads `Retry-After` as delta-seconds or an IMF-fixdate; None when absent or unusable."""
|
|
342
|
+
if header is None:
|
|
343
|
+
return None
|
|
344
|
+
value = header.strip()
|
|
345
|
+
if DELTA_SECONDS.fullmatch(value):
|
|
346
|
+
return int(value)
|
|
347
|
+
try:
|
|
348
|
+
at = email.utils.parsedate_to_datetime(value)
|
|
349
|
+
except (TypeError, ValueError):
|
|
350
|
+
return None
|
|
351
|
+
if at.tzinfo is None:
|
|
352
|
+
return None
|
|
353
|
+
remaining = at.timestamp() - (time.time() if now is None else now)
|
|
354
|
+
return max(0, -int(-remaining // 1))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def transport_error(operation: Operation, error: httpx.TransportError) -> APIConnectionError:
|
|
358
|
+
"""The error for a failed attempt; `settle` marks it `outcome_unknown` when it may have run."""
|
|
359
|
+
never_sent = isinstance(error, NOT_SENT_ERRORS)
|
|
360
|
+
if isinstance(error, httpx.TimeoutException):
|
|
361
|
+
return APITimeoutError(
|
|
362
|
+
"The request to Timbro timed out",
|
|
363
|
+
code="connection_failed" if never_sent else "request_timed_out",
|
|
364
|
+
operation=operation,
|
|
365
|
+
)
|
|
366
|
+
return APIConnectionError("Timbro could not be reached", operation=operation)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def may_have_arrived(error: httpx.TransportError) -> bool:
|
|
370
|
+
return not isinstance(error, NOT_SENT_ERRORS)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def settle(
|
|
374
|
+
operation: Operation, request: httpx.Request, error: TimbroError, reached: bool
|
|
375
|
+
) -> TimbroError:
|
|
376
|
+
"""Marks the final error of a call whose state change may have happened anyway.
|
|
377
|
+
|
|
378
|
+
`reached` is true when any attempt may have arrived at Timbro. A write is then
|
|
379
|
+
uncertain when the last attempt got no answer, a server failure, or word that the
|
|
380
|
+
first attempt is still running.
|
|
381
|
+
"""
|
|
382
|
+
status = getattr(error, "status", None)
|
|
383
|
+
uncertain = (
|
|
384
|
+
isinstance(error, APIConnectionError)
|
|
385
|
+
or (isinstance(status, int) and status >= 500)
|
|
386
|
+
or error.code == IN_PROGRESS_CODE
|
|
387
|
+
)
|
|
388
|
+
error.outcome_unknown = reached and uncertain and not operation.is_read
|
|
389
|
+
if error.outcome_unknown and isinstance(error, APIConnectionError):
|
|
390
|
+
error.code = "outcome_unknown"
|
|
391
|
+
error._idempotency_key_sent = "Idempotency-Key" in request.headers
|
|
392
|
+
return error
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def log_response(operation: Operation, response: httpx.Response, attempt: int) -> None:
|
|
396
|
+
logger.debug(
|
|
397
|
+
"%s %s -> %s request_id=%s attempt=%d",
|
|
398
|
+
operation.method,
|
|
399
|
+
operation.path,
|
|
400
|
+
response.status_code,
|
|
401
|
+
response.headers.get("request-id"),
|
|
402
|
+
attempt,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def decode_response(
|
|
407
|
+
operation: Operation,
|
|
408
|
+
response: httpx.Response,
|
|
409
|
+
content: bytes,
|
|
410
|
+
expect: Mapping[str, str] | None = None,
|
|
411
|
+
) -> dict[str, Any] | None:
|
|
412
|
+
"""Returns the JSON object of a success, None for 204, or raises the mapped error.
|
|
413
|
+
|
|
414
|
+
Each `expect` member names the identifier the answer must carry, so an answer
|
|
415
|
+
about another resource is refused.
|
|
416
|
+
"""
|
|
417
|
+
status = response.status_code
|
|
418
|
+
if not 200 <= status < 300:
|
|
419
|
+
raise failure(operation, response, content)
|
|
420
|
+
require_bounded(operation, response, content)
|
|
421
|
+
if status == 204:
|
|
422
|
+
return None
|
|
423
|
+
body = parse_json_object(content)
|
|
424
|
+
if body is None:
|
|
425
|
+
raise unexpected(operation, response, "Timbro returned a success without a JSON object")
|
|
426
|
+
for member, identifier in (expect or {}).items():
|
|
427
|
+
if not same_identifier(body.get(member), identifier):
|
|
428
|
+
raise unexpected(
|
|
429
|
+
operation,
|
|
430
|
+
response,
|
|
431
|
+
f"Timbro answered with another {member} than the one requested",
|
|
432
|
+
code="response_identity_mismatch",
|
|
433
|
+
)
|
|
434
|
+
if operation.page:
|
|
435
|
+
require_page(operation, response, body)
|
|
436
|
+
return body
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def require_page(operation: Operation, response: httpx.Response, body: dict[str, Any]) -> None:
|
|
440
|
+
"""Refuses a list page that is malformed or promises more rows without a cursor."""
|
|
441
|
+
cursor = body.get("nextCursor")
|
|
442
|
+
if (
|
|
443
|
+
body.get("object") != "list"
|
|
444
|
+
or not isinstance(body.get("data"), list)
|
|
445
|
+
or not isinstance(body.get("hasMore"), bool)
|
|
446
|
+
or not (cursor is None or isinstance(cursor, str))
|
|
447
|
+
):
|
|
448
|
+
raise unexpected(operation, response, "Timbro returned a malformed list page")
|
|
449
|
+
if body["hasMore"] and (cursor is None or cursor == ""):
|
|
450
|
+
raise unexpected(
|
|
451
|
+
operation,
|
|
452
|
+
response,
|
|
453
|
+
"Timbro returned a page that promised more rows without a cursor",
|
|
454
|
+
code="collection_page_not_advancing",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def decode_file(operation: Operation, response: httpx.Response, content: bytes) -> FileResponse:
|
|
459
|
+
"""Returns the bytes of a successful download, or raises the mapped error."""
|
|
460
|
+
if not 200 <= response.status_code < 300:
|
|
461
|
+
raise failure(operation, response, content)
|
|
462
|
+
require_bounded(operation, response, content)
|
|
463
|
+
media_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
|
464
|
+
return FileResponse(
|
|
465
|
+
content=content,
|
|
466
|
+
media_type=media_type,
|
|
467
|
+
headers={name.lower(): value for name, value in response.headers.items()},
|
|
468
|
+
request_id=response.headers.get("request-id"),
|
|
469
|
+
operation=operation,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def failure(operation: Operation, response: httpx.Response, content: bytes) -> TimbroError:
|
|
474
|
+
status = response.status_code
|
|
475
|
+
if 300 <= status < 400:
|
|
476
|
+
return unexpected(operation, response, "Timbro answered with a redirect, which is refused")
|
|
477
|
+
body = parse_json_object(content)
|
|
478
|
+
if body is None or not isinstance(body.get("code"), str):
|
|
479
|
+
return unexpected(operation, response, f"Timbro returned HTTP {status} without a Problem")
|
|
480
|
+
return problem_error(operation, response, body)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def declares_too_much(response: httpx.Response) -> bool:
|
|
484
|
+
"""True when `Content-Length` already exceeds the response limit."""
|
|
485
|
+
length = response.headers.get("content-length", "")
|
|
486
|
+
return length.isdigit() and int(length) > MAX_RESPONSE_BYTES
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def require_bounded(operation: Operation, response: httpx.Response, content: bytes) -> None:
|
|
490
|
+
if len(content) > MAX_RESPONSE_BYTES or declares_too_much(response):
|
|
491
|
+
raise unexpected(
|
|
492
|
+
operation,
|
|
493
|
+
response,
|
|
494
|
+
f"Timbro returned more than {MAX_RESPONSE_BYTES // (1024 * 1024)} MiB",
|
|
495
|
+
code="response_too_large",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def same_identifier(actual: object, expected: str) -> bool:
|
|
500
|
+
"""Compares canonical UUIDs without regard to letter case, and anything else exactly."""
|
|
501
|
+
if not isinstance(actual, str):
|
|
502
|
+
return False
|
|
503
|
+
if UUID.fullmatch(actual) and UUID.fullmatch(expected):
|
|
504
|
+
return actual.lower() == expected.lower()
|
|
505
|
+
return actual == expected
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def problem_error(operation: Operation, response: httpx.Response, body: dict[str, Any]) -> APIError:
|
|
509
|
+
status = response.status_code
|
|
510
|
+
title = body.get("title")
|
|
511
|
+
title = title if isinstance(title, str) and title != "" else f"HTTP {status}"
|
|
512
|
+
detail = body.get("detail")
|
|
513
|
+
message = detail if isinstance(detail, str) and detail != "" else title
|
|
514
|
+
request_id = body.get("requestId")
|
|
515
|
+
return api_error_class(status)(
|
|
516
|
+
message,
|
|
517
|
+
code=body["code"],
|
|
518
|
+
status=status,
|
|
519
|
+
title=title,
|
|
520
|
+
problems=problem_details(body.get("problems")),
|
|
521
|
+
retry_after_seconds=retry_after_seconds(response.headers.get("Retry-After")),
|
|
522
|
+
body=body,
|
|
523
|
+
request_id=request_id
|
|
524
|
+
if isinstance(request_id, str) and request_id != ""
|
|
525
|
+
else response.headers.get("request-id"),
|
|
526
|
+
operation=operation,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def problem_details(problems: object) -> tuple[ProblemDetail, ...]:
|
|
531
|
+
if not isinstance(problems, list):
|
|
532
|
+
return ()
|
|
533
|
+
return tuple(
|
|
534
|
+
ProblemDetail(path=entry["path"], code=entry["code"], message=entry["message"])
|
|
535
|
+
for entry in problems
|
|
536
|
+
if isinstance(entry, dict)
|
|
537
|
+
and all(isinstance(entry.get(key), str) for key in ("path", "code", "message"))
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def parse_json_object(content: bytes) -> dict[str, Any] | None:
|
|
542
|
+
try:
|
|
543
|
+
value = json.loads(content)
|
|
544
|
+
except ValueError:
|
|
545
|
+
return None
|
|
546
|
+
return value if isinstance(value, dict) else None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def unexpected(
|
|
550
|
+
operation: Operation,
|
|
551
|
+
response: httpx.Response,
|
|
552
|
+
message: str,
|
|
553
|
+
*,
|
|
554
|
+
code: str = "unexpected_http_error",
|
|
555
|
+
) -> UnexpectedResponseError:
|
|
556
|
+
status = response.status_code
|
|
557
|
+
return UnexpectedResponseError(
|
|
558
|
+
message,
|
|
559
|
+
code=code,
|
|
560
|
+
status=status,
|
|
561
|
+
retryable=status in RETRYABLE_STATUSES
|
|
562
|
+
or retry_after_seconds(response.headers.get("Retry-After")) is not None,
|
|
563
|
+
request_id=response.headers.get("request-id"),
|
|
564
|
+
operation=operation,
|
|
565
|
+
)
|