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.
@@ -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
+ )