clientwright 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.
Files changed (102) hide show
  1. clientwright/__init__.py +179 -0
  2. clientwright/__version__.py +1 -0
  3. clientwright/adapters/__init__.py +3 -0
  4. clientwright/adapters/_httpx_shared.py +831 -0
  5. clientwright/adapters/_lazy.py +30 -0
  6. clientwright/adapters/aiohttp/__init__.py +45 -0
  7. clientwright/adapters/aiohttp/_imports.py +30 -0
  8. clientwright/adapters/aiohttp/adapter.py +236 -0
  9. clientwright/adapters/aiohttp/capabilities.py +81 -0
  10. clientwright/adapters/aiohttp/classify.py +59 -0
  11. clientwright/adapters/aiohttp/errors.py +57 -0
  12. clientwright/adapters/aiohttp/middleware.py +103 -0
  13. clientwright/adapters/aiohttp/normalize.py +64 -0
  14. clientwright/adapters/aiohttp/options.py +16 -0
  15. clientwright/adapters/aiohttp/trace.py +109 -0
  16. clientwright/adapters/aiohttp/views.py +108 -0
  17. clientwright/adapters/httpx/__init__.py +45 -0
  18. clientwright/adapters/httpx/_imports.py +17 -0
  19. clientwright/adapters/httpx/adapter.py +39 -0
  20. clientwright/adapters/httpx/capabilities.py +9 -0
  21. clientwright/adapters/httpx/classify.py +14 -0
  22. clientwright/adapters/httpx/errors.py +35 -0
  23. clientwright/adapters/httpx/normalize.py +27 -0
  24. clientwright/adapters/httpx/normalize_sync.py +27 -0
  25. clientwright/adapters/httpx/transport.py +40 -0
  26. clientwright/adapters/httpx/views.py +46 -0
  27. clientwright/adapters/httpx2/__init__.py +46 -0
  28. clientwright/adapters/httpx2/_imports.py +18 -0
  29. clientwright/adapters/httpx2/adapter.py +37 -0
  30. clientwright/adapters/httpx2/capabilities.py +9 -0
  31. clientwright/adapters/httpx2/classify.py +14 -0
  32. clientwright/adapters/httpx2/errors.py +36 -0
  33. clientwright/adapters/httpx2/normalize.py +27 -0
  34. clientwright/adapters/httpx2/normalize_sync.py +27 -0
  35. clientwright/adapters/httpx2/transport.py +35 -0
  36. clientwright/adapters/httpx2/views.py +45 -0
  37. clientwright/adapters/observability/__init__.py +26 -0
  38. clientwright/adapters/observability/_metrics/__init__.py +1 -0
  39. clientwright/adapters/observability/_metrics/prometheus.py +200 -0
  40. clientwright/adapters/observability/_tracing/__init__.py +3 -0
  41. clientwright/adapters/observability/_tracing/otel.py +61 -0
  42. clientwright/adapters/requests/__init__.py +45 -0
  43. clientwright/adapters/requests/_imports.py +21 -0
  44. clientwright/adapters/requests/adapter.py +206 -0
  45. clientwright/adapters/requests/capabilities.py +80 -0
  46. clientwright/adapters/requests/classify.py +62 -0
  47. clientwright/adapters/requests/errors.py +40 -0
  48. clientwright/adapters/requests/normalize.py +63 -0
  49. clientwright/adapters/requests/views.py +121 -0
  50. clientwright/adapters/urllib3/__init__.py +48 -0
  51. clientwright/adapters/urllib3/_imports.py +18 -0
  52. clientwright/adapters/urllib3/adapter.py +260 -0
  53. clientwright/adapters/urllib3/capabilities.py +86 -0
  54. clientwright/adapters/urllib3/classify.py +46 -0
  55. clientwright/adapters/urllib3/errors.py +55 -0
  56. clientwright/adapters/urllib3/normalize.py +51 -0
  57. clientwright/adapters/urllib3/views.py +119 -0
  58. clientwright/contrib/__init__.py +3 -0
  59. clientwright/contrib/deadline.py +107 -0
  60. clientwright/contrib/dishka.py +80 -0
  61. clientwright/core/__init__.py +6 -0
  62. clientwright/core/balancer/__init__.py +1 -0
  63. clientwright/core/balancer/policy.py +23 -0
  64. clientwright/core/capabilities.py +156 -0
  65. clientwright/core/config.py +367 -0
  66. clientwright/core/contracts/__init__.py +33 -0
  67. clientwright/core/contracts/adapter.py +62 -0
  68. clientwright/core/contracts/context.py +31 -0
  69. clientwright/core/contracts/message.py +118 -0
  70. clientwright/core/contracts/observability.py +92 -0
  71. clientwright/core/contracts/settings.py +140 -0
  72. clientwright/core/engine/__init__.py +1 -0
  73. clientwright/core/engine/aio.py +246 -0
  74. clientwright/core/engine/base.py +65 -0
  75. clientwright/core/engine/redirects.py +64 -0
  76. clientwright/core/engine/suppress.py +30 -0
  77. clientwright/core/engine/sync.py +241 -0
  78. clientwright/core/errors.py +113 -0
  79. clientwright/core/model.py +138 -0
  80. clientwright/core/native.py +84 -0
  81. clientwright/core/options.py +46 -0
  82. clientwright/core/plan.py +190 -0
  83. clientwright/core/policy/__init__.py +1 -0
  84. clientwright/core/policy/budget.py +76 -0
  85. clientwright/core/policy/circuit.py +169 -0
  86. clientwright/core/policy/concurrency.py +98 -0
  87. clientwright/core/policy/retry.py +80 -0
  88. clientwright/core/policy/timeout.py +64 -0
  89. clientwright/core/registry.py +75 -0
  90. clientwright/core/telemetry/__init__.py +6 -0
  91. clientwright/core/telemetry/emitter.py +163 -0
  92. clientwright/core/telemetry/names.py +61 -0
  93. clientwright/core/telemetry/null.py +89 -0
  94. clientwright/core/telemetry/redaction.py +24 -0
  95. clientwright/core/testing/__init__.py +7 -0
  96. clientwright/core/testing/doubles.py +107 -0
  97. clientwright/core/testing/origin.py +201 -0
  98. clientwright/py.typed +0 -0
  99. clientwright-0.1.0.dist-info/METADATA +210 -0
  100. clientwright-0.1.0.dist-info/RECORD +102 -0
  101. clientwright-0.1.0.dist-info/WHEEL +4 -0
  102. clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,75 @@
1
+ """Adapter registry: string names to lazy import paths.
2
+
3
+ ``import clientwright`` pays nothing: adapter modules load only when resolved,
4
+ at which point the adapter's ``_imports`` guard raises a friendly install hint
5
+ if the matching extra is absent. Capability modules are zero-dependency and
6
+ always importable. Dynamic string imports keep the core->adapters import-linter
7
+ contract intact by construction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .errors import UnknownAdapterError
16
+
17
+ if TYPE_CHECKING:
18
+ from .capabilities import AdapterCapabilities
19
+
20
+ _ADAPTERS: dict[str, str] = {
21
+ "httpx": "clientwright.adapters.httpx.adapter:HttpxAdapter",
22
+ "httpx2": "clientwright.adapters.httpx2.adapter:HttpxAdapter",
23
+ "aiohttp": "clientwright.adapters.aiohttp.adapter:AiohttpAdapter",
24
+ "requests": "clientwright.adapters.requests.adapter:RequestsAdapter",
25
+ "urllib3": "clientwright.adapters.urllib3.adapter:Urllib3Adapter",
26
+ }
27
+
28
+ _CAPABILITIES: dict[str, str] = {
29
+ "httpx": "clientwright.adapters.httpx.capabilities:CAPABILITIES",
30
+ "httpx2": "clientwright.adapters.httpx2.capabilities:CAPABILITIES",
31
+ "aiohttp": "clientwright.adapters.aiohttp.capabilities:CAPABILITIES",
32
+ "requests": "clientwright.adapters.requests.capabilities:CAPABILITIES",
33
+ "urllib3": "clientwright.adapters.urllib3.capabilities:CAPABILITIES",
34
+ }
35
+
36
+
37
+ def _load(target: str) -> Any:
38
+ module_path, _, attribute = target.partition(":")
39
+ module = importlib.import_module(module_path)
40
+ return getattr(module, attribute)
41
+
42
+
43
+ def register_adapter(name: str, adapter_target: str, capabilities_target: str) -> None:
44
+ """Register a third-party adapter: ``"module.path:Class"`` strings, lazily loaded."""
45
+ for label, target in (("adapter", adapter_target), ("capabilities", capabilities_target)):
46
+ if ":" not in target:
47
+ raise ValueError(f"{label} target must look like 'module.path:Attribute', got {target!r}")
48
+ _ADAPTERS[name] = adapter_target
49
+ _CAPABILITIES[name] = capabilities_target
50
+
51
+
52
+ def registered_adapters() -> tuple[str, ...]:
53
+ return tuple(sorted(_ADAPTERS))
54
+
55
+
56
+ def resolve_adapter(name: str) -> Any:
57
+ """Load and return the adapter class registered under ``name``."""
58
+ try:
59
+ target = _ADAPTERS[name]
60
+ except KeyError:
61
+ raise UnknownAdapterError(name, registered_adapters()) from None
62
+ return _load(target)
63
+
64
+
65
+ def capability_records() -> dict[str, AdapterCapabilities]:
66
+ """Capability record of every registered adapter; importable without extras."""
67
+ return {name: _load(target) for name, target in sorted(_CAPABILITIES.items())}
68
+
69
+
70
+ __all__ = [
71
+ "capability_records",
72
+ "register_adapter",
73
+ "registered_adapters",
74
+ "resolve_adapter",
75
+ ]
@@ -0,0 +1,6 @@
1
+ """Single telemetry implementation for the whole library.
2
+
3
+ Adapters never write a metric or a log line: they call ``send`` and the engine
4
+ drives the one ``ClientTelemetry`` emitter, so the schema physically cannot
5
+ diverge between backends.
6
+ """
@@ -0,0 +1,163 @@
1
+ """ClientTelemetry - the single emitter driving logs, metrics and spans.
2
+
3
+ Observability closes in ``finally`` for every path: an attempt that dies with
4
+ an exception still decrements in-flight, ends its span and records metrics -
5
+ the legacy kit's event hooks lost every exception outcome.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+
13
+ from ..config import ObservabilityConfig
14
+ from ..contracts.observability import ClientMetricsProtocol, SpanProtocol, TracerProtocol
15
+ from ..model import Attempt, Outcome, RequestInfo
16
+ from .names import OUTCOME_SUCCESS, ROUTE_UNKNOWN, STATUS_NONE
17
+ from .null import NullMetrics, NullTracer
18
+ from .redaction import redact_url
19
+
20
+
21
+ def outcome_label(outcome: Outcome) -> str:
22
+ return OUTCOME_SUCCESS if outcome.kind is None else outcome.kind.value
23
+
24
+
25
+ def status_label(outcome: Outcome) -> str:
26
+ return STATUS_NONE if outcome.status_code is None else str(outcome.status_code)
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class CallObservation:
31
+ """Mutable per-call observation state carried through the engine."""
32
+
33
+ span: SpanProtocol
34
+ started: float
35
+ hops: int = 0
36
+ attempts: int = 0
37
+
38
+
39
+ class ClientTelemetry:
40
+ """One instance per built client; adapter/seam are burned in as constants."""
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ service: str,
46
+ adapter: str,
47
+ seam: str,
48
+ config: ObservabilityConfig,
49
+ metrics: ClientMetricsProtocol | None,
50
+ tracer: TracerProtocol | None,
51
+ ) -> None:
52
+ self._service = service
53
+ self._adapter = adapter
54
+ self._seam = seam
55
+ self._config = config
56
+ self._metrics: ClientMetricsProtocol = metrics if (metrics and config.metrics) else NullMetrics()
57
+ self._tracer: TracerProtocol = tracer if (tracer and config.tracing) else NullTracer()
58
+ self._logger = logging.getLogger(f"clientwright.client.{service}")
59
+ self._log_enabled = config.logging
60
+
61
+ @property
62
+ def tracer(self) -> TracerProtocol:
63
+ return self._tracer
64
+
65
+ def _safe_url(self, info: RequestInfo) -> str:
66
+ return redact_url(info.url, self._config.sensitive_query_params)
67
+
68
+ def call_start(self, info: RequestInfo, started: float) -> CallObservation:
69
+ span = self._tracer.start_span(
70
+ f"HTTP {info.method}",
71
+ attributes={
72
+ "http.request.method": info.method,
73
+ "server.origin": info.origin,
74
+ "url.full": self._safe_url(info),
75
+ },
76
+ )
77
+ self._metrics.inflight_delta(
78
+ service=self._service, adapter=self._adapter, seam=self._seam, origin=info.origin, delta=1
79
+ )
80
+ if self._log_enabled:
81
+ self._logger.debug(
82
+ "HTTP call started",
83
+ extra={"method": info.method, "url": self._safe_url(info), "origin": info.origin},
84
+ )
85
+ return CallObservation(span=span, started=started)
86
+
87
+ def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None:
88
+ self._metrics.record_attempt(
89
+ service=self._service,
90
+ adapter=self._adapter,
91
+ seam=self._seam,
92
+ method=info.method,
93
+ origin=info.origin,
94
+ outcome=outcome_label(attempt.outcome),
95
+ duration=attempt.duration,
96
+ )
97
+
98
+ def redirect_hop(self, observation: CallObservation) -> None:
99
+ observation.hops += 1
100
+ self._metrics.record_redirect_hop(service=self._service, adapter=self._adapter, seam=self._seam)
101
+
102
+ def retry_skipped(self, reason: str) -> None:
103
+ self._metrics.record_retry_skipped(service=self._service, adapter=self._adapter, seam=self._seam, reason=reason)
104
+
105
+ def circuit_state_changed(self, key: str, state: str) -> None:
106
+ self._metrics.record_circuit_state(service=self._service, adapter=self._adapter, key=key, state=state)
107
+
108
+ def uninstrumented_call(self) -> None:
109
+ """A call bypassed the instrumentation seam (e.g. aiohttp middlewares=())."""
110
+ self._metrics.record_uninstrumented_call(service=self._service, adapter=self._adapter, seam=self._seam)
111
+
112
+ def call_end(self, observation: CallObservation, info: RequestInfo, outcome: Outcome, duration: float) -> None:
113
+ route = info.route or ROUTE_UNKNOWN
114
+ try:
115
+ self._metrics.record_call(
116
+ service=self._service,
117
+ adapter=self._adapter,
118
+ seam=self._seam,
119
+ method=info.method,
120
+ origin=info.origin,
121
+ route=route,
122
+ status=status_label(outcome),
123
+ outcome=outcome_label(outcome),
124
+ duration=duration,
125
+ )
126
+ self._metrics.inflight_delta(
127
+ service=self._service, adapter=self._adapter, seam=self._seam, origin=info.origin, delta=-1
128
+ )
129
+ if self._log_enabled:
130
+ extra = {
131
+ "method": info.method,
132
+ "url": self._safe_url(info),
133
+ "origin": info.origin,
134
+ "outcome": outcome_label(outcome),
135
+ "status": status_label(outcome),
136
+ "duration": round(duration, 6),
137
+ "attempts": observation.attempts,
138
+ "hops": observation.hops,
139
+ }
140
+ if outcome.kind is None:
141
+ self._logger.log(self._config.success_log_level, "HTTP call finished", extra=extra)
142
+ else:
143
+ self._logger.warning("HTTP call failed", extra=extra)
144
+ finally:
145
+ if outcome.status_code is not None:
146
+ observation.span.set_attribute("http.response.status_code", outcome.status_code)
147
+ if outcome.kind is not None:
148
+ observation.span.record_failure(outcome_label(outcome))
149
+ observation.span.end()
150
+
151
+ def record_body_duration(self, info: RequestInfo, duration: float) -> None:
152
+ self._metrics.record_body_duration(
153
+ service=self._service,
154
+ adapter=self._adapter,
155
+ seam=self._seam,
156
+ method=info.method,
157
+ origin=info.origin,
158
+ route=info.route or ROUTE_UNKNOWN,
159
+ duration=duration,
160
+ )
161
+
162
+
163
+ __all__ = ["CallObservation", "ClientTelemetry", "outcome_label", "status_label"]
@@ -0,0 +1,61 @@
1
+ """FROZEN metric names and label sets - a wire contract with dashboards.
2
+
3
+ Changing anything here is a breaking change of the observability contract and
4
+ requires a dashboard migration plan; see docs/advanced/migration.md for the
5
+ mapping principles from legacy ``rest_client_*``-style names.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Final
11
+
12
+ REQUESTS_TOTAL: Final = "http_client_requests_total"
13
+ REQUEST_DURATION: Final = "http_client_request_duration_seconds"
14
+ BODY_DURATION: Final = "http_client_body_duration_seconds"
15
+ ATTEMPTS_TOTAL: Final = "http_client_attempts_total"
16
+ ATTEMPT_DURATION: Final = "http_client_attempt_duration_seconds"
17
+ INFLIGHT: Final = "http_client_inflight"
18
+ CIRCUIT_STATE: Final = "http_client_circuit_state"
19
+ REDIRECT_HOPS_TOTAL: Final = "http_client_redirect_hops_total"
20
+ RETRY_SKIPPED_TOTAL: Final = "http_client_retry_skipped_total"
21
+ UNINSTRUMENTED_CALLS_TOTAL: Final = "http_client_uninstrumented_calls_total"
22
+
23
+ LABELS_CALL: Final = ("service", "adapter", "seam", "method", "origin", "route", "status", "outcome")
24
+ LABELS_DURATION: Final = ("service", "adapter", "seam", "method", "origin", "route")
25
+ LABELS_ATTEMPT: Final = ("service", "adapter", "seam", "method", "origin", "outcome")
26
+ LABELS_INFLIGHT: Final = ("service", "adapter", "seam", "origin")
27
+ LABELS_CIRCUIT: Final = ("service", "adapter", "key")
28
+ LABELS_SEAM: Final = ("service", "adapter", "seam")
29
+ LABELS_RETRY_SKIPPED: Final = ("service", "adapter", "seam", "reason")
30
+
31
+ OUTCOME_SUCCESS: Final = "success"
32
+ STATUS_NONE: Final = "none"
33
+ ROUTE_UNKNOWN: Final = "unknown"
34
+
35
+ # Inherited verbatim from service-observability's DEFAULT_REST_BUCKETS: tuned
36
+ # for REST latencies and already burned into existing dashboards.
37
+ DEFAULT_BUCKETS: Final = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
38
+
39
+ __all__ = [
40
+ "ATTEMPTS_TOTAL",
41
+ "ATTEMPT_DURATION",
42
+ "BODY_DURATION",
43
+ "CIRCUIT_STATE",
44
+ "DEFAULT_BUCKETS",
45
+ "INFLIGHT",
46
+ "LABELS_ATTEMPT",
47
+ "LABELS_CALL",
48
+ "LABELS_CIRCUIT",
49
+ "LABELS_DURATION",
50
+ "LABELS_INFLIGHT",
51
+ "LABELS_RETRY_SKIPPED",
52
+ "LABELS_SEAM",
53
+ "OUTCOME_SUCCESS",
54
+ "REDIRECT_HOPS_TOTAL",
55
+ "REQUESTS_TOTAL",
56
+ "REQUEST_DURATION",
57
+ "RETRY_SKIPPED_TOTAL",
58
+ "ROUTE_UNKNOWN",
59
+ "STATUS_NONE",
60
+ "UNINSTRUMENTED_CALLS_TOTAL",
61
+ ]
@@ -0,0 +1,89 @@
1
+ """Null observability backends: emitters never branch on ``is None``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, MutableMapping
6
+
7
+
8
+ class NullMetrics:
9
+ """Accepts every record and does nothing."""
10
+
11
+ def record_call(
12
+ self,
13
+ *,
14
+ service: str,
15
+ adapter: str,
16
+ seam: str,
17
+ method: str,
18
+ origin: str,
19
+ route: str,
20
+ status: str,
21
+ outcome: str,
22
+ duration: float,
23
+ ) -> None:
24
+ return None
25
+
26
+ def record_body_duration(
27
+ self,
28
+ *,
29
+ service: str,
30
+ adapter: str,
31
+ seam: str,
32
+ method: str,
33
+ origin: str,
34
+ route: str,
35
+ duration: float,
36
+ ) -> None:
37
+ return None
38
+
39
+ def record_attempt(
40
+ self,
41
+ *,
42
+ service: str,
43
+ adapter: str,
44
+ seam: str,
45
+ method: str,
46
+ origin: str,
47
+ outcome: str,
48
+ duration: float,
49
+ ) -> None:
50
+ return None
51
+
52
+ def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
53
+ return None
54
+
55
+ def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
56
+ return None
57
+
58
+ def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
59
+ return None
60
+
61
+ def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
62
+ return None
63
+
64
+ def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
65
+ return None
66
+
67
+
68
+ class NullSpan:
69
+ def set_attribute(self, key: str, value: str | int | float | bool) -> None:
70
+ return None
71
+
72
+ def record_failure(self, description: str) -> None:
73
+ return None
74
+
75
+ def end(self) -> None:
76
+ return None
77
+
78
+
79
+ class NullTracer:
80
+ _SPAN = NullSpan()
81
+
82
+ def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> NullSpan:
83
+ return self._SPAN
84
+
85
+ def inject_context(self, headers: MutableMapping[str, str]) -> None:
86
+ return None
87
+
88
+
89
+ __all__ = ["NullMetrics", "NullSpan", "NullTracer"]
@@ -0,0 +1,24 @@
1
+ """Secret redaction for logs and spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
7
+
8
+ REDACTED = "[redacted]"
9
+
10
+
11
+ def redact_headers(headers: Mapping[str, str], sensitive: frozenset[str]) -> dict[str, str]:
12
+ return {name: (REDACTED if name.lower() in sensitive else value) for name, value in headers.items()}
13
+
14
+
15
+ def redact_url(url: str, sensitive_params: frozenset[str]) -> str:
16
+ parts = urlsplit(url)
17
+ if not parts.query:
18
+ return url
19
+ pairs = parse_qsl(parts.query, keep_blank_values=True)
20
+ cleaned = [(name, REDACTED if name.lower() in sensitive_params else value) for name, value in pairs]
21
+ return urlunsplit(parts._replace(query=urlencode(cleaned)))
22
+
23
+
24
+ __all__ = ["REDACTED", "redact_headers", "redact_url"]
@@ -0,0 +1,7 @@
1
+ """Test doubles and a fault-injecting origin server, stdlib only."""
2
+
3
+ from .doubles import ManualClock as ManualClock
4
+ from .doubles import RecordingMetrics as RecordingMetrics
5
+ from .origin import OriginServer as OriginServer
6
+
7
+ __all__ = ["ManualClock", "OriginServer", "RecordingMetrics"]
@@ -0,0 +1,107 @@
1
+ """Deterministic doubles for engine and telemetry tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ class ManualClock:
9
+ """Monotonic clock advanced by hand."""
10
+
11
+ def __init__(self, start: float = 0.0) -> None:
12
+ self._now = start
13
+
14
+ def __call__(self) -> float:
15
+ return self._now
16
+
17
+ def advance(self, seconds: float) -> None:
18
+ self._now += seconds
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class RecordingMetrics:
23
+ """ClientMetricsProtocol implementation that remembers every record."""
24
+
25
+ calls: list[dict[str, object]] = field(default_factory=list)
26
+ attempts: list[dict[str, object]] = field(default_factory=list)
27
+ body_durations: list[dict[str, object]] = field(default_factory=list)
28
+ inflight: list[dict[str, object]] = field(default_factory=list)
29
+ circuit_states: list[dict[str, object]] = field(default_factory=list)
30
+ redirect_hops: list[dict[str, object]] = field(default_factory=list)
31
+ retry_skips: list[dict[str, object]] = field(default_factory=list)
32
+ uninstrumented: list[dict[str, object]] = field(default_factory=list)
33
+
34
+ def record_call(
35
+ self,
36
+ *,
37
+ service: str,
38
+ adapter: str,
39
+ seam: str,
40
+ method: str,
41
+ origin: str,
42
+ route: str,
43
+ status: str,
44
+ outcome: str,
45
+ duration: float,
46
+ ) -> None:
47
+ self.calls.append(
48
+ {
49
+ "service": service,
50
+ "adapter": adapter,
51
+ "seam": seam,
52
+ "method": method,
53
+ "origin": origin,
54
+ "route": route,
55
+ "status": status,
56
+ "outcome": outcome,
57
+ "duration": duration,
58
+ }
59
+ )
60
+
61
+ def record_body_duration(
62
+ self,
63
+ *,
64
+ service: str,
65
+ adapter: str,
66
+ seam: str,
67
+ method: str,
68
+ origin: str,
69
+ route: str,
70
+ duration: float,
71
+ ) -> None:
72
+ self.body_durations.append({"method": method, "origin": origin, "route": route, "duration": duration})
73
+
74
+ def record_attempt(
75
+ self,
76
+ *,
77
+ service: str,
78
+ adapter: str,
79
+ seam: str,
80
+ method: str,
81
+ origin: str,
82
+ outcome: str,
83
+ duration: float,
84
+ ) -> None:
85
+ self.attempts.append({"method": method, "origin": origin, "outcome": outcome, "duration": duration})
86
+
87
+ def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
88
+ self.inflight.append({"origin": origin, "delta": delta})
89
+
90
+ def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
91
+ self.circuit_states.append({"key": key, "state": state})
92
+
93
+ def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
94
+ self.redirect_hops.append({"adapter": adapter})
95
+
96
+ def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
97
+ self.retry_skips.append({"reason": reason})
98
+
99
+ def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
100
+ self.uninstrumented.append({"adapter": adapter})
101
+
102
+ @property
103
+ def inflight_balance(self) -> int:
104
+ return sum(int(record["delta"]) for record in self.inflight) # type: ignore[call-overload]
105
+
106
+
107
+ __all__ = ["ManualClock", "RecordingMetrics"]