threecommon 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.
@@ -0,0 +1,80 @@
1
+ """Retry policy + backoff math.
2
+
3
+ Pure module — no I/O, no timing. The sync/async HTTP clients call
4
+ [compute_backoff][threecommon._core.retry.compute_backoff] and pass the
5
+ result to ``time.sleep`` / ``asyncio.sleep`` themselves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+ from dataclasses import dataclass
12
+
13
+ from threecommon.config import RetryDelay
14
+
15
+ #: HTTP status codes the SDK considers retryable for idempotent methods.
16
+ RETRYABLE_STATUSES: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504})
17
+
18
+ #: HTTP methods the SDK retries automatically. ``POST`` and ``DELETE`` opt
19
+ #: in via an explicit idempotency key.
20
+ IDEMPOTENT_METHODS: frozenset[str] = frozenset({"GET", "PATCH", "PUT"})
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RetryPolicy:
25
+ """Bundled retry configuration for the HTTP client."""
26
+
27
+ max_retries: int
28
+ initial_seconds: float
29
+ max_seconds: float
30
+ jitter: bool
31
+
32
+ @classmethod
33
+ def from_delay(cls, max_retries: int, delay: RetryDelay) -> RetryPolicy:
34
+ """Build a [RetryPolicy] from the public [RetryDelay]."""
35
+ return cls(
36
+ max_retries=max_retries,
37
+ initial_seconds=delay.initial_seconds,
38
+ max_seconds=delay.max_seconds,
39
+ jitter=delay.jitter,
40
+ )
41
+
42
+
43
+ def is_idempotent(method: str, *, has_idempotency_key: bool) -> bool:
44
+ """Whether the SDK may safely retry a request with this method."""
45
+ return has_idempotency_key or method.upper() in IDEMPOTENT_METHODS
46
+
47
+
48
+ def is_retryable_status(status: int) -> bool:
49
+ """Whether ``status`` is in the retry set (alongside method idempotency)."""
50
+ return status in RETRYABLE_STATUSES
51
+
52
+
53
+ def compute_backoff(
54
+ *,
55
+ attempt: int,
56
+ retry_after_seconds: float | None,
57
+ policy: RetryPolicy,
58
+ ) -> float:
59
+ """Return the next sleep duration in seconds.
60
+
61
+ When ``retry_after_seconds`` is provided (e.g. parsed from a
62
+ ``Retry-After`` header) it takes precedence, capped at
63
+ ``policy.max_seconds``. Otherwise: exponential ``2**attempt * initial``,
64
+ capped, with optional full-jitter randomization.
65
+ """
66
+ if retry_after_seconds is not None and retry_after_seconds >= 0:
67
+ return min(retry_after_seconds, policy.max_seconds)
68
+
69
+ safe_attempt = max(attempt, 0)
70
+ exp: float = policy.initial_seconds * (2**safe_attempt)
71
+ capped: float = min(exp, policy.max_seconds)
72
+
73
+ if not policy.jitter:
74
+ return capped
75
+ if capped <= 0:
76
+ return 0.0
77
+ # Full jitter: pick uniformly in [0, capped). secrets.randbits avoids
78
+ # the math.random global-state lock and isn't security-sensitive here.
79
+ bits = secrets.randbits(32)
80
+ return float(capped * (bits / (1 << 32)))
@@ -0,0 +1,77 @@
1
+ """Threecommon-Client-Telemetry header builder + last-request tracker.
2
+
3
+ The header carries SDK version, language, and the previous request's
4
+ latency snapshot.
5
+
6
+ both sync and async paths share one [Telemetry][threecommon._core.telemetry.Telemetry]
7
+ instance per client.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import threading
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _Snapshot:
19
+ method: str
20
+ path: str
21
+ status: int
22
+ duration_ms: int
23
+
24
+
25
+ class Telemetry:
26
+ """Tracks one previous-request snapshot and emits the header value."""
27
+
28
+ __slots__ = ("_enabled", "_last", "_lock")
29
+
30
+ def __init__(self, *, enabled: bool) -> None:
31
+ self._enabled = enabled
32
+ self._last: _Snapshot | None = None
33
+ self._lock = threading.Lock()
34
+
35
+ @property
36
+ def enabled(self) -> bool:
37
+ with self._lock:
38
+ return self._enabled
39
+
40
+ def disable(self) -> None:
41
+ """Turn telemetry off and clear the cached snapshot."""
42
+ with self._lock:
43
+ self._enabled = False
44
+ self._last = None
45
+
46
+ def record(self, *, method: str, path: str, status: int, duration_seconds: float) -> None:
47
+ """Store a snapshot of the just-completed request. No-op when disabled."""
48
+ with self._lock:
49
+ if not self._enabled:
50
+ return
51
+ self._last = _Snapshot(
52
+ method=method,
53
+ path=path,
54
+ status=status,
55
+ duration_ms=int(duration_seconds * 1000),
56
+ )
57
+
58
+ def header_value(self, *, sdk_version: str, api_version: str) -> str | None:
59
+ """Return the JSON header value, or ``None`` to omit the header."""
60
+ with self._lock:
61
+ if not self._enabled:
62
+ return None
63
+ last = self._last
64
+
65
+ payload: dict[str, object] = {
66
+ "lang": "python",
67
+ "sdk": sdk_version,
68
+ "api": api_version,
69
+ }
70
+ if last is not None:
71
+ payload["last"] = {
72
+ "m": last.method,
73
+ "p": last.path,
74
+ "s": last.status,
75
+ "d": last.duration_ms,
76
+ }
77
+ return json.dumps(payload, separators=(",", ":"))
@@ -0,0 +1,31 @@
1
+ """URL builder. Pure function — no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlencode
6
+
7
+
8
+ def build_url(
9
+ *,
10
+ base_url: str,
11
+ api_path: str,
12
+ path: str,
13
+ query: dict[str, str] | None = None,
14
+ ) -> str:
15
+ """Concatenate ``base_url + api_path + path`` and append a sorted query string.
16
+
17
+ Trailing slashes on ``base_url`` are trimmed; missing leading slashes on
18
+ ``path`` are added. Query keys are stable-sorted for deterministic output.
19
+ """
20
+ base = base_url.rstrip("/")
21
+ p = path if path.startswith("/") else "/" + path
22
+ out = base + api_path + p
23
+
24
+ if not query:
25
+ return out
26
+
27
+ pairs = [(k, v) for k, v in sorted(query.items()) if v]
28
+ if not pairs:
29
+ return out
30
+
31
+ return f"{out}?{urlencode(pairs)}"
@@ -0,0 +1,8 @@
1
+ """Generated Pydantic v2 models — DO NOT EDIT.
2
+
3
+ Regenerated from `../../openapi/spec.yaml` via `uv run datamodel-codegen ...`.
4
+ The auto-generated names (`V1EventsGetResponse`,`Datum`, `Status1`, ...) are intentional
5
+
6
+ Treat this package as a contract-reference artifact: nothing inside should be imported from outside
7
+ the SDK.
8
+ """