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.
- clientwright/__init__.py +179 -0
- clientwright/__version__.py +1 -0
- clientwright/adapters/__init__.py +3 -0
- clientwright/adapters/_httpx_shared.py +831 -0
- clientwright/adapters/_lazy.py +30 -0
- clientwright/adapters/aiohttp/__init__.py +45 -0
- clientwright/adapters/aiohttp/_imports.py +30 -0
- clientwright/adapters/aiohttp/adapter.py +236 -0
- clientwright/adapters/aiohttp/capabilities.py +81 -0
- clientwright/adapters/aiohttp/classify.py +59 -0
- clientwright/adapters/aiohttp/errors.py +57 -0
- clientwright/adapters/aiohttp/middleware.py +103 -0
- clientwright/adapters/aiohttp/normalize.py +64 -0
- clientwright/adapters/aiohttp/options.py +16 -0
- clientwright/adapters/aiohttp/trace.py +109 -0
- clientwright/adapters/aiohttp/views.py +108 -0
- clientwright/adapters/httpx/__init__.py +45 -0
- clientwright/adapters/httpx/_imports.py +17 -0
- clientwright/adapters/httpx/adapter.py +39 -0
- clientwright/adapters/httpx/capabilities.py +9 -0
- clientwright/adapters/httpx/classify.py +14 -0
- clientwright/adapters/httpx/errors.py +35 -0
- clientwright/adapters/httpx/normalize.py +27 -0
- clientwright/adapters/httpx/normalize_sync.py +27 -0
- clientwright/adapters/httpx/transport.py +40 -0
- clientwright/adapters/httpx/views.py +46 -0
- clientwright/adapters/httpx2/__init__.py +46 -0
- clientwright/adapters/httpx2/_imports.py +18 -0
- clientwright/adapters/httpx2/adapter.py +37 -0
- clientwright/adapters/httpx2/capabilities.py +9 -0
- clientwright/adapters/httpx2/classify.py +14 -0
- clientwright/adapters/httpx2/errors.py +36 -0
- clientwright/adapters/httpx2/normalize.py +27 -0
- clientwright/adapters/httpx2/normalize_sync.py +27 -0
- clientwright/adapters/httpx2/transport.py +35 -0
- clientwright/adapters/httpx2/views.py +45 -0
- clientwright/adapters/observability/__init__.py +26 -0
- clientwright/adapters/observability/_metrics/__init__.py +1 -0
- clientwright/adapters/observability/_metrics/prometheus.py +200 -0
- clientwright/adapters/observability/_tracing/__init__.py +3 -0
- clientwright/adapters/observability/_tracing/otel.py +61 -0
- clientwright/adapters/requests/__init__.py +45 -0
- clientwright/adapters/requests/_imports.py +21 -0
- clientwright/adapters/requests/adapter.py +206 -0
- clientwright/adapters/requests/capabilities.py +80 -0
- clientwright/adapters/requests/classify.py +62 -0
- clientwright/adapters/requests/errors.py +40 -0
- clientwright/adapters/requests/normalize.py +63 -0
- clientwright/adapters/requests/views.py +121 -0
- clientwright/adapters/urllib3/__init__.py +48 -0
- clientwright/adapters/urllib3/_imports.py +18 -0
- clientwright/adapters/urllib3/adapter.py +260 -0
- clientwright/adapters/urllib3/capabilities.py +86 -0
- clientwright/adapters/urllib3/classify.py +46 -0
- clientwright/adapters/urllib3/errors.py +55 -0
- clientwright/adapters/urllib3/normalize.py +51 -0
- clientwright/adapters/urllib3/views.py +119 -0
- clientwright/contrib/__init__.py +3 -0
- clientwright/contrib/deadline.py +107 -0
- clientwright/contrib/dishka.py +80 -0
- clientwright/core/__init__.py +6 -0
- clientwright/core/balancer/__init__.py +1 -0
- clientwright/core/balancer/policy.py +23 -0
- clientwright/core/capabilities.py +156 -0
- clientwright/core/config.py +367 -0
- clientwright/core/contracts/__init__.py +33 -0
- clientwright/core/contracts/adapter.py +62 -0
- clientwright/core/contracts/context.py +31 -0
- clientwright/core/contracts/message.py +118 -0
- clientwright/core/contracts/observability.py +92 -0
- clientwright/core/contracts/settings.py +140 -0
- clientwright/core/engine/__init__.py +1 -0
- clientwright/core/engine/aio.py +246 -0
- clientwright/core/engine/base.py +65 -0
- clientwright/core/engine/redirects.py +64 -0
- clientwright/core/engine/suppress.py +30 -0
- clientwright/core/engine/sync.py +241 -0
- clientwright/core/errors.py +113 -0
- clientwright/core/model.py +138 -0
- clientwright/core/native.py +84 -0
- clientwright/core/options.py +46 -0
- clientwright/core/plan.py +190 -0
- clientwright/core/policy/__init__.py +1 -0
- clientwright/core/policy/budget.py +76 -0
- clientwright/core/policy/circuit.py +169 -0
- clientwright/core/policy/concurrency.py +98 -0
- clientwright/core/policy/retry.py +80 -0
- clientwright/core/policy/timeout.py +64 -0
- clientwright/core/registry.py +75 -0
- clientwright/core/telemetry/__init__.py +6 -0
- clientwright/core/telemetry/emitter.py +163 -0
- clientwright/core/telemetry/names.py +61 -0
- clientwright/core/telemetry/null.py +89 -0
- clientwright/core/telemetry/redaction.py +24 -0
- clientwright/core/testing/__init__.py +7 -0
- clientwright/core/testing/doubles.py +107 -0
- clientwright/core/testing/origin.py +201 -0
- clientwright/py.typed +0 -0
- clientwright-0.1.0.dist-info/METADATA +210 -0
- clientwright-0.1.0.dist-info/RECORD +102 -0
- clientwright-0.1.0.dist-info/WHEEL +4 -0
- clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The httpx2 adapter: the shared family builder bound to the httpx2 SDK.
|
|
2
|
+
|
|
3
|
+
The class keeps the httpx adapter's name (``HttpxAdapter``) on purpose:
|
|
4
|
+
public names in the paired adapters are identical, so migrating a service is
|
|
5
|
+
one extra and one import path. The registry name is ``"httpx2"``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .._httpx_shared import FamilyAdapter
|
|
11
|
+
from ._imports import httpx2
|
|
12
|
+
from .capabilities import CAPABILITIES
|
|
13
|
+
from .errors import translate_call_error
|
|
14
|
+
from .normalize import AsyncHttpxNormalizer
|
|
15
|
+
from .normalize_sync import SyncHttpxNormalizer
|
|
16
|
+
from .transport import (
|
|
17
|
+
AsyncEngineTransport,
|
|
18
|
+
AsyncProxyRouterTransport,
|
|
19
|
+
SyncEngineTransport,
|
|
20
|
+
SyncProxyRouterTransport,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HttpxAdapter(FamilyAdapter):
|
|
25
|
+
name = "httpx2"
|
|
26
|
+
capabilities = CAPABILITIES
|
|
27
|
+
sdk = httpx2
|
|
28
|
+
async_normalizer = AsyncHttpxNormalizer
|
|
29
|
+
sync_normalizer = SyncHttpxNormalizer
|
|
30
|
+
async_engine_transport = AsyncEngineTransport
|
|
31
|
+
sync_engine_transport = SyncEngineTransport
|
|
32
|
+
async_proxy_router = AsyncProxyRouterTransport
|
|
33
|
+
sync_proxy_router = SyncProxyRouterTransport
|
|
34
|
+
translate = staticmethod(translate_call_error)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["HttpxAdapter"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""httpx2 exception -> FailureKind classification (shared family logic)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ...core.model import FailureKind
|
|
6
|
+
from .._httpx_shared import classify_family_error
|
|
7
|
+
from ._imports import httpx2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def classify_error(exc: BaseException) -> FailureKind:
|
|
11
|
+
return classify_family_error(httpx2, exc)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["classify_error"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Kernel errors dual-inherited into the httpx2 family.
|
|
2
|
+
|
|
3
|
+
A user's ``except httpx2.HTTPError`` (or ``except httpx2.TimeoutException``)
|
|
4
|
+
keeps working when clientwright raises on its own authority. Class names
|
|
5
|
+
mirror the httpx adapter on purpose.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ...core.errors import CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
|
|
11
|
+
from .._httpx_shared import make_error_translator
|
|
12
|
+
from ._imports import httpx2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpxCircuitOpenError(CircuitOpenError, httpx2.HTTPError):
|
|
16
|
+
"""Circuit open, catchable as httpx2.HTTPError."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpxDeadlineExceededError(DeadlineExceededError, httpx2.TimeoutException):
|
|
20
|
+
"""Total deadline exhausted, catchable as httpx2.TimeoutException."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HttpxTooManyRedirectsError(TooManyRedirectsError, httpx2.TooManyRedirects):
|
|
24
|
+
"""Owned redirect limit exceeded, catchable as httpx2.TooManyRedirects."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
translate_call_error = make_error_translator(
|
|
28
|
+
HttpxCircuitOpenError, HttpxDeadlineExceededError, HttpxTooManyRedirectsError
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"HttpxCircuitOpenError",
|
|
33
|
+
"HttpxDeadlineExceededError",
|
|
34
|
+
"HttpxTooManyRedirectsError",
|
|
35
|
+
"translate_call_error",
|
|
36
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Async normalizer binding: shared family logic over the httpx2 SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from .._httpx_shared import AsyncFamilyNormalizer, AsyncTimedStreamMixin
|
|
8
|
+
from ._imports import httpx2
|
|
9
|
+
from .views import HttpxRequestView
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _TimedAsyncStream(AsyncTimedStreamMixin, httpx2.AsyncByteStream):
|
|
13
|
+
"""Times body consumption and reports read failures exactly once."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncHttpxNormalizer(AsyncFamilyNormalizer):
|
|
17
|
+
def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
default_timeout,
|
|
20
|
+
clock,
|
|
21
|
+
sdk=httpx2,
|
|
22
|
+
request_view=lambda native: HttpxRequestView(native, default_timeout),
|
|
23
|
+
timed_stream=_TimedAsyncStream,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["AsyncHttpxNormalizer"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Sync normalizer binding: same contract, blocking body operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from .._httpx_shared import SyncFamilyNormalizer, SyncTimedStreamMixin
|
|
8
|
+
from ._imports import httpx2
|
|
9
|
+
from .views import HttpxRequestView
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _TimedSyncStream(SyncTimedStreamMixin, httpx2.SyncByteStream):
|
|
13
|
+
"""Times body consumption and reports read failures exactly once."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncHttpxNormalizer(SyncFamilyNormalizer):
|
|
17
|
+
def __init__(self, default_timeout: dict[str, float | None], clock: Callable[[], float]) -> None:
|
|
18
|
+
super().__init__(
|
|
19
|
+
default_timeout,
|
|
20
|
+
clock,
|
|
21
|
+
sdk=httpx2,
|
|
22
|
+
request_view=lambda native: HttpxRequestView(native, default_timeout),
|
|
23
|
+
timed_stream=_TimedSyncStream,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["SyncHttpxNormalizer"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Engine-driven httpx2 transports: shared mixin logic bound to httpx2 bases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._httpx_shared import (
|
|
6
|
+
AsyncEngineTransportMixin,
|
|
7
|
+
AsyncProxyRouterMixin,
|
|
8
|
+
SyncEngineTransportMixin,
|
|
9
|
+
SyncProxyRouterMixin,
|
|
10
|
+
)
|
|
11
|
+
from ._imports import httpx2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncEngineTransport(AsyncEngineTransportMixin, httpx2.AsyncBaseTransport):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SyncEngineTransport(SyncEngineTransportMixin, httpx2.BaseTransport):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncProxyRouterTransport(AsyncProxyRouterMixin, httpx2.AsyncBaseTransport):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SyncProxyRouterTransport(SyncProxyRouterMixin, httpx2.BaseTransport):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AsyncEngineTransport",
|
|
32
|
+
"AsyncProxyRouterTransport",
|
|
33
|
+
"SyncEngineTransport",
|
|
34
|
+
"SyncProxyRouterTransport",
|
|
35
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""RequestView/ResponseView bindings over httpx2 objects.
|
|
2
|
+
|
|
3
|
+
Same names as the httpx adapter on purpose: migrating a service is one extra
|
|
4
|
+
and one import path.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
|
|
11
|
+
from .._httpx_shared import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION, FamilyRequestView, FamilyResponseView
|
|
12
|
+
from ._imports import httpx2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _ReplayStream(httpx2.SyncByteStream, httpx2.AsyncByteStream):
|
|
16
|
+
"""A byte stream that can be iterated any number of times."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, content: bytes) -> None:
|
|
19
|
+
self._content = content
|
|
20
|
+
|
|
21
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
22
|
+
yield self._content
|
|
23
|
+
|
|
24
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
25
|
+
yield self._content
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpxRequestView(FamilyRequestView):
|
|
29
|
+
__slots__ = ()
|
|
30
|
+
|
|
31
|
+
def __init__(self, request: httpx2.Request, default_timeout: dict[str, float | None]) -> None:
|
|
32
|
+
super().__init__(request, default_timeout, sdk=httpx2, replay_stream=_ReplayStream)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HttpxResponseView(FamilyResponseView):
|
|
36
|
+
__slots__ = ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"IDEMPOTENT_EXTENSION",
|
|
41
|
+
"ROUTE_EXTENSION",
|
|
42
|
+
"HttpxRequestView",
|
|
43
|
+
"HttpxResponseView",
|
|
44
|
+
"_ReplayStream",
|
|
45
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Observability backends behind extras; the core records via protocols only.
|
|
2
|
+
|
|
3
|
+
Attribute access is lazy so this package imports cleanly with no extras
|
|
4
|
+
installed; touching a backend without its extra raises the backend's own
|
|
5
|
+
install-hint ImportError.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from .._lazy import lazy_attribute
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._metrics.prometheus import PrometheusClientMetrics as PrometheusClientMetrics
|
|
14
|
+
from ._tracing.otel import OpenTelemetryTracer as OpenTelemetryTracer
|
|
15
|
+
|
|
16
|
+
_EXPORTS = {
|
|
17
|
+
"OpenTelemetryTracer": "_tracing.otel",
|
|
18
|
+
"PrometheusClientMetrics": "_metrics.prometheus",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name: str) -> Any:
|
|
23
|
+
return lazy_attribute(__name__, globals(), _EXPORTS, name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["OpenTelemetryTracer", "PrometheusClientMetrics"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Metrics backends."""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Prometheus backend for the frozen ``http_client_*`` metric families.
|
|
2
|
+
|
|
3
|
+
Instances are cached per (prefix, registry): repeated construction returns the
|
|
4
|
+
same object instead of raising Prometheus duplicate-registration errors - the
|
|
5
|
+
same guarantee the legacy ``MetricsMeta`` singleton gave.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import weakref
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from prometheus_client import REGISTRY, Counter, Gauge, Histogram
|
|
17
|
+
from prometheus_client.registry import CollectorRegistry
|
|
18
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
19
|
+
raise ImportError("Prometheus metrics require clientwright[metrics]; install it.") from exc
|
|
20
|
+
|
|
21
|
+
from ....core.telemetry import names
|
|
22
|
+
|
|
23
|
+
_PREFIX_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
24
|
+
|
|
25
|
+
# Keyed by the registry OBJECT (weakly): id() of a dead registry can be
|
|
26
|
+
# recycled, which would hand back a sink bound to the wrong registry.
|
|
27
|
+
_INSTANCES: weakref.WeakKeyDictionary[CollectorRegistry, dict[str | None, PrometheusClientMetrics]] = (
|
|
28
|
+
weakref.WeakKeyDictionary()
|
|
29
|
+
)
|
|
30
|
+
_INSTANCES_LOCK = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _metric_name(name: str, prefix: str | None) -> str:
|
|
34
|
+
if prefix is None:
|
|
35
|
+
return name
|
|
36
|
+
if not _PREFIX_PATTERN.match(prefix):
|
|
37
|
+
raise ValueError(f"Invalid metric prefix {prefix!r}: must match {_PREFIX_PATTERN.pattern}")
|
|
38
|
+
return f"{prefix}_{name}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PrometheusClientMetrics:
|
|
42
|
+
"""ClientMetricsProtocol backend over prometheus-client."""
|
|
43
|
+
|
|
44
|
+
def __new__(
|
|
45
|
+
cls,
|
|
46
|
+
prefix: str | None = None,
|
|
47
|
+
registry: CollectorRegistry = REGISTRY,
|
|
48
|
+
buckets: tuple[float, ...] = names.DEFAULT_BUCKETS,
|
|
49
|
+
) -> PrometheusClientMetrics:
|
|
50
|
+
with _INSTANCES_LOCK:
|
|
51
|
+
per_registry = _INSTANCES.get(registry)
|
|
52
|
+
if per_registry is None:
|
|
53
|
+
per_registry = {}
|
|
54
|
+
_INSTANCES[registry] = per_registry
|
|
55
|
+
instance = per_registry.get(prefix)
|
|
56
|
+
if instance is None:
|
|
57
|
+
instance = super().__new__(cls)
|
|
58
|
+
instance._init(prefix, registry, buckets)
|
|
59
|
+
per_registry[prefix] = instance
|
|
60
|
+
return instance
|
|
61
|
+
|
|
62
|
+
def _init(self, prefix: str | None, registry: CollectorRegistry, buckets: tuple[float, ...]) -> None:
|
|
63
|
+
self._requests_total = Counter(
|
|
64
|
+
_metric_name(names.REQUESTS_TOTAL, prefix),
|
|
65
|
+
"Total number of outbound HTTP client calls",
|
|
66
|
+
names.LABELS_CALL,
|
|
67
|
+
registry=registry,
|
|
68
|
+
)
|
|
69
|
+
self._request_duration = Histogram(
|
|
70
|
+
_metric_name(names.REQUEST_DURATION, prefix),
|
|
71
|
+
"HTTP client call duration in seconds (boundary declared per adapter)",
|
|
72
|
+
names.LABELS_DURATION,
|
|
73
|
+
registry=registry,
|
|
74
|
+
buckets=list(buckets),
|
|
75
|
+
)
|
|
76
|
+
self._body_duration = Histogram(
|
|
77
|
+
_metric_name(names.BODY_DURATION, prefix),
|
|
78
|
+
"HTTP client response body read duration in seconds",
|
|
79
|
+
names.LABELS_DURATION,
|
|
80
|
+
registry=registry,
|
|
81
|
+
buckets=list(buckets),
|
|
82
|
+
)
|
|
83
|
+
self._attempts_total = Counter(
|
|
84
|
+
_metric_name(names.ATTEMPTS_TOTAL, prefix),
|
|
85
|
+
"Total number of physical attempts",
|
|
86
|
+
names.LABELS_ATTEMPT,
|
|
87
|
+
registry=registry,
|
|
88
|
+
)
|
|
89
|
+
self._attempt_duration = Histogram(
|
|
90
|
+
_metric_name(names.ATTEMPT_DURATION, prefix),
|
|
91
|
+
"Physical attempt duration in seconds",
|
|
92
|
+
names.LABELS_ATTEMPT,
|
|
93
|
+
registry=registry,
|
|
94
|
+
buckets=list(buckets),
|
|
95
|
+
)
|
|
96
|
+
self._inflight = Gauge(
|
|
97
|
+
_metric_name(names.INFLIGHT, prefix),
|
|
98
|
+
"Number of in-flight HTTP client calls",
|
|
99
|
+
names.LABELS_INFLIGHT,
|
|
100
|
+
registry=registry,
|
|
101
|
+
)
|
|
102
|
+
self._circuit_state = Gauge(
|
|
103
|
+
_metric_name(names.CIRCUIT_STATE, prefix),
|
|
104
|
+
"Circuit state per key: 0 closed, 1 half-open, 2 open",
|
|
105
|
+
names.LABELS_CIRCUIT,
|
|
106
|
+
registry=registry,
|
|
107
|
+
)
|
|
108
|
+
self._redirect_hops = Counter(
|
|
109
|
+
_metric_name(names.REDIRECT_HOPS_TOTAL, prefix),
|
|
110
|
+
"Redirect hops followed by the engine",
|
|
111
|
+
names.LABELS_SEAM,
|
|
112
|
+
registry=registry,
|
|
113
|
+
)
|
|
114
|
+
self._retry_skipped = Counter(
|
|
115
|
+
_metric_name(names.RETRY_SKIPPED_TOTAL, prefix),
|
|
116
|
+
"Retries the policy wanted but could not perform",
|
|
117
|
+
names.LABELS_RETRY_SKIPPED,
|
|
118
|
+
registry=registry,
|
|
119
|
+
)
|
|
120
|
+
self._uninstrumented = Counter(
|
|
121
|
+
_metric_name(names.UNINSTRUMENTED_CALLS_TOTAL, prefix),
|
|
122
|
+
"Calls that bypassed the instrumentation seam",
|
|
123
|
+
names.LABELS_SEAM,
|
|
124
|
+
registry=registry,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
_STATE_VALUES = {"closed": 0, "half_open": 1, "open": 2}
|
|
128
|
+
|
|
129
|
+
def record_call(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
service: str,
|
|
133
|
+
adapter: str,
|
|
134
|
+
seam: str,
|
|
135
|
+
method: str,
|
|
136
|
+
origin: str,
|
|
137
|
+
route: str,
|
|
138
|
+
status: str,
|
|
139
|
+
outcome: str,
|
|
140
|
+
duration: float,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._requests_total.labels(service, adapter, seam, method, origin, route, status, outcome).inc()
|
|
143
|
+
self._request_duration.labels(service, adapter, seam, method, origin, route).observe(duration)
|
|
144
|
+
|
|
145
|
+
def record_body_duration(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
service: str,
|
|
149
|
+
adapter: str,
|
|
150
|
+
seam: str,
|
|
151
|
+
method: str,
|
|
152
|
+
origin: str,
|
|
153
|
+
route: str,
|
|
154
|
+
duration: float,
|
|
155
|
+
) -> None:
|
|
156
|
+
self._body_duration.labels(service, adapter, seam, method, origin, route).observe(duration)
|
|
157
|
+
|
|
158
|
+
def record_attempt(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
service: str,
|
|
162
|
+
adapter: str,
|
|
163
|
+
seam: str,
|
|
164
|
+
method: str,
|
|
165
|
+
origin: str,
|
|
166
|
+
outcome: str,
|
|
167
|
+
duration: float,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._attempts_total.labels(service, adapter, seam, method, origin, outcome).inc()
|
|
170
|
+
self._attempt_duration.labels(service, adapter, seam, method, origin, outcome).observe(duration)
|
|
171
|
+
|
|
172
|
+
def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
|
|
173
|
+
gauge = self._inflight.labels(service, adapter, seam, origin)
|
|
174
|
+
if delta > 0:
|
|
175
|
+
gauge.inc(float(delta))
|
|
176
|
+
elif delta < 0:
|
|
177
|
+
gauge.dec(float(abs(delta)))
|
|
178
|
+
|
|
179
|
+
def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
|
|
180
|
+
self._circuit_state.labels(service, adapter, key).set(self._STATE_VALUES.get(state, 0))
|
|
181
|
+
|
|
182
|
+
def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
|
|
183
|
+
self._redirect_hops.labels(service, adapter, seam).inc()
|
|
184
|
+
|
|
185
|
+
def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
|
|
186
|
+
self._retry_skipped.labels(service, adapter, seam, reason).inc()
|
|
187
|
+
|
|
188
|
+
def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
|
|
189
|
+
self._uninstrumented.labels(service, adapter, seam).inc()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_client_metrics(
|
|
193
|
+
prefix: str | None = None,
|
|
194
|
+
registry: Any = REGISTRY,
|
|
195
|
+
buckets: tuple[float, ...] | None = None,
|
|
196
|
+
) -> PrometheusClientMetrics:
|
|
197
|
+
return PrometheusClientMetrics(prefix, registry, buckets or names.DEFAULT_BUCKETS)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = ["PrometheusClientMetrics", "get_client_metrics"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""OpenTelemetry backend for the SDK-free tracer protocol.
|
|
2
|
+
|
|
3
|
+
The client span is attached to the ambient context for its lifetime, so
|
|
4
|
+
``inject_context`` propagates THIS span to the server (not merely the parent),
|
|
5
|
+
and any spans the server links to point at the client call. Attach and detach
|
|
6
|
+
happen in the same task: the engine opens the span and ends it in a finally
|
|
7
|
+
block without crossing task boundaries.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping, MutableMapping
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from opentelemetry import context as otel_context
|
|
16
|
+
from opentelemetry import propagate, trace
|
|
17
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode, set_span_in_context
|
|
18
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
19
|
+
raise ImportError("OpenTelemetry tracing requires clientwright[tracing]; install it.") from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenTelemetrySpan:
|
|
23
|
+
"""SpanProtocol backend over an OTel span attached to the current context."""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_span", "_token")
|
|
26
|
+
|
|
27
|
+
def __init__(self, span: trace.Span, token: object) -> None:
|
|
28
|
+
self._span = span
|
|
29
|
+
self._token = token
|
|
30
|
+
|
|
31
|
+
def set_attribute(self, key: str, value: str | int | float | bool) -> None:
|
|
32
|
+
self._span.set_attribute(key, value)
|
|
33
|
+
|
|
34
|
+
def record_failure(self, description: str) -> None:
|
|
35
|
+
self._span.set_status(Status(StatusCode.ERROR, description))
|
|
36
|
+
|
|
37
|
+
def end(self) -> None:
|
|
38
|
+
try:
|
|
39
|
+
otel_context.detach(self._token) # type: ignore[arg-type]
|
|
40
|
+
finally:
|
|
41
|
+
self._span.end()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OpenTelemetryTracer:
|
|
45
|
+
"""TracerProtocol backend over opentelemetry-api."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_tracer",)
|
|
48
|
+
|
|
49
|
+
def __init__(self, tracer_provider: trace.TracerProvider | None = None) -> None:
|
|
50
|
+
self._tracer = trace.get_tracer("clientwright", tracer_provider=tracer_provider)
|
|
51
|
+
|
|
52
|
+
def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> OpenTelemetrySpan:
|
|
53
|
+
span = self._tracer.start_span(name, kind=SpanKind.CLIENT, attributes=dict(attributes))
|
|
54
|
+
token = otel_context.attach(set_span_in_context(span))
|
|
55
|
+
return OpenTelemetrySpan(span, token)
|
|
56
|
+
|
|
57
|
+
def inject_context(self, headers: MutableMapping[str, str]) -> None:
|
|
58
|
+
propagate.inject(headers)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["OpenTelemetrySpan", "OpenTelemetryTracer"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""requests adapter (``[requests]`` extra): real Session, engine underneath.
|
|
2
|
+
|
|
3
|
+
SDK-backed names resolve lazily (see ``clientwright.adapters._lazy``): importing
|
|
4
|
+
this package never imports requests, so the capabilities matrix stays
|
|
5
|
+
extras-free. The per-call channel comes from the zero-dependency core and is
|
|
6
|
+
therefore eager.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from ...core.options import CallOptions as CallOptions
|
|
14
|
+
from ...core.options import call_options as call_options
|
|
15
|
+
from .._lazy import lazy_attribute
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .adapter import RequestsAdapter as RequestsAdapter
|
|
19
|
+
from .capabilities import CAPABILITIES as CAPABILITIES
|
|
20
|
+
from .errors import RequestsCircuitOpenError as RequestsCircuitOpenError
|
|
21
|
+
from .errors import RequestsDeadlineExceededError as RequestsDeadlineExceededError
|
|
22
|
+
from .errors import RequestsTooManyRedirectsError as RequestsTooManyRedirectsError
|
|
23
|
+
|
|
24
|
+
_EXPORTS = {
|
|
25
|
+
"CAPABILITIES": "capabilities",
|
|
26
|
+
"RequestsAdapter": "adapter",
|
|
27
|
+
"RequestsCircuitOpenError": "errors",
|
|
28
|
+
"RequestsDeadlineExceededError": "errors",
|
|
29
|
+
"RequestsTooManyRedirectsError": "errors",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str) -> Any:
|
|
34
|
+
return lazy_attribute(__name__, globals(), _EXPORTS, name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"CAPABILITIES",
|
|
39
|
+
"CallOptions",
|
|
40
|
+
"RequestsAdapter",
|
|
41
|
+
"RequestsCircuitOpenError",
|
|
42
|
+
"RequestsDeadlineExceededError",
|
|
43
|
+
"RequestsTooManyRedirectsError",
|
|
44
|
+
"call_options",
|
|
45
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Lazy import guard for the optional requests stack.
|
|
2
|
+
|
|
3
|
+
Importing this module fails with a friendly message when the ``requests``
|
|
4
|
+
extra is not installed. Every requests submodule imports its third-party
|
|
5
|
+
symbols from here. urllib3 is re-exported too: requests depends on it, and the
|
|
6
|
+
classifier needs the underlying exception types to tell a mid-stream
|
|
7
|
+
disconnect from a refused connection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
_INSTALL_HINT = "requests support requires clientwright[requests]; install it."
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import requests
|
|
16
|
+
import urllib3
|
|
17
|
+
from requests.adapters import HTTPAdapter
|
|
18
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
19
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
20
|
+
|
|
21
|
+
__all__ = ["HTTPAdapter", "requests", "urllib3"]
|