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,30 @@
1
+ """Lazy re-exports for adapter packages.
2
+
3
+ Importing an adapter package must NOT import its SDK. The registry reaches
4
+ ``<adapter>.capabilities`` *through* the package, and Python executes a
5
+ package's ``__init__`` before any submodule of it - so an eager
6
+ ``from .adapter import ...`` there would drag the SDK in and break
7
+ ``capabilities_matrix()`` on a bare install, which is exactly the promise the
8
+ zero-dependency core exists to keep.
9
+
10
+ Each adapter therefore maps its public names to submodules and resolves them on
11
+ first attribute access; the SDK's install hint surfaces then, not at import.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ from typing import Any
18
+
19
+
20
+ def lazy_attribute(package: str, namespace: dict[str, Any], exports: dict[str, str], name: str) -> Any:
21
+ """Resolve ``name`` from its submodule and cache it in the package namespace."""
22
+ submodule = exports.get(name)
23
+ if submodule is None:
24
+ raise AttributeError(f"module {package!r} has no attribute {name!r}")
25
+ value = getattr(importlib.import_module(f".{submodule}", package), name)
26
+ namespace[name] = value
27
+ return value
28
+
29
+
30
+ __all__ = ["lazy_attribute"]
@@ -0,0 +1,45 @@
1
+ """aiohttp adapter (``[aiohttp]`` extra): real ClientSession, engine underneath.
2
+
3
+ Names resolve lazily (see ``clientwright.adapters._lazy``): importing this
4
+ package never imports aiohttp, so the capabilities matrix stays extras-free.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .._lazy import lazy_attribute
12
+
13
+ if TYPE_CHECKING:
14
+ from .adapter import AiohttpAdapter as AiohttpAdapter
15
+ from .capabilities import CAPABILITIES as CAPABILITIES
16
+ from .errors import AiohttpCircuitOpenError as AiohttpCircuitOpenError
17
+ from .errors import AiohttpDeadlineExceededError as AiohttpDeadlineExceededError
18
+ from .errors import AiohttpTooManyRedirectsError as AiohttpTooManyRedirectsError
19
+ from .options import CallOptions as CallOptions
20
+ from .options import call_options as call_options
21
+
22
+ _EXPORTS = {
23
+ "CAPABILITIES": "capabilities",
24
+ "AiohttpAdapter": "adapter",
25
+ "AiohttpCircuitOpenError": "errors",
26
+ "AiohttpDeadlineExceededError": "errors",
27
+ "AiohttpTooManyRedirectsError": "errors",
28
+ "CallOptions": "options",
29
+ "call_options": "options",
30
+ }
31
+
32
+
33
+ def __getattr__(name: str) -> Any:
34
+ return lazy_attribute(__name__, globals(), _EXPORTS, name)
35
+
36
+
37
+ __all__ = [
38
+ "CAPABILITIES",
39
+ "AiohttpAdapter",
40
+ "AiohttpCircuitOpenError",
41
+ "AiohttpDeadlineExceededError",
42
+ "AiohttpTooManyRedirectsError",
43
+ "CallOptions",
44
+ "call_options",
45
+ ]
@@ -0,0 +1,30 @@
1
+ """Lazy import guard for the optional aiohttp stack.
2
+
3
+ Importing this module fails with a friendly message when the ``aiohttp`` extra
4
+ is not installed, so ``import clientwright`` never pays the cost (or the
5
+ failure). Every aiohttp submodule imports its third-party symbols from here.
6
+
7
+ The adapter needs aiohttp>=3.12: earlier releases have no client middleware,
8
+ which is the only seam that can mutate requests and own the retry loop.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+
15
+ _INSTALL_HINT = "aiohttp support requires clientwright[aiohttp]; install it."
16
+ _VERSION_HINT = (
17
+ "clientwright's aiohttp adapter requires aiohttp>=3.12 (client middleware); "
18
+ "the installed version has no 'middlewares' parameter on ClientSession."
19
+ )
20
+
21
+ try:
22
+ import aiohttp
23
+ from yarl import URL
24
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
25
+ raise ImportError(_INSTALL_HINT) from exc
26
+
27
+ if "middlewares" not in inspect.signature(aiohttp.ClientSession.__init__).parameters: # pragma: no cover
28
+ raise ImportError(_VERSION_HINT)
29
+
30
+ __all__ = ["URL", "aiohttp"]
@@ -0,0 +1,236 @@
1
+ """The aiohttp adapter: builds real ClientSession objects with the engine underneath.
2
+
3
+ aiohttp constraints this builder honors (verified against aiohttp 3.12):
4
+ - the session-level total timeout would wrap ALL our retries and backoff sleeps
5
+ (middleware runs inside the ``_request`` timer), so ``ClientTimeout.total``
6
+ is always None and the engine's monotonic deadline is the only total;
7
+ - ``ceil_threshold`` is raised so aiohttp never rounds deadlines up to whole
8
+ seconds (the default ceils everything above 5s);
9
+ - the native one-shot idempotent retry is invisible to middleware and is
10
+ silenced via the private ``_retry_connection`` attribute (declared DEGRADED);
11
+ - ``ClientSession`` must be constructed inside a running event loop.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ssl
17
+ from collections.abc import Mapping
18
+ from typing import Any
19
+ from urllib.request import getproxies
20
+
21
+ from ...core.capabilities import Capability
22
+ from ...core.config import ClientConfig, is_set, resolve
23
+ from ...core.contracts.adapter import AdapterDeps
24
+ from ...core.engine.aio import AsyncAttemptEngine
25
+ from ...core.errors import UnsupportedCapabilityError
26
+ from ...core.model import ResolvedTimeouts
27
+ from ...core.native import validate_native
28
+ from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
29
+ from ...core.policy.timeout import base_timeouts
30
+ from ...core.telemetry.emitter import ClientTelemetry
31
+ from ._imports import aiohttp
32
+ from .capabilities import CAPABILITIES
33
+ from .errors import translate_call_error
34
+ from .middleware import EngineMiddleware, ProxyRouter
35
+ from .normalize import AsyncAiohttpNormalizer
36
+ from .trace import build_trace_config
37
+
38
+ # aiohttp's DEFAULT_TIMEOUT is ClientTimeout(total=5*60, sock_connect=30); the
39
+ # closest per-phase reading: 30s to connect, no read/write/pool phases.
40
+ _NATIVE_TIMEOUT_DEFAULTS = ResolvedTimeouts(connect=30.0, read=None, write=None, pool_acquire=None)
41
+
42
+ # Above this threshold aiohttp ceils absolute deadlines to whole seconds; a
43
+ # value no timeout reaches disables the rounding entirely.
44
+ _NO_CEIL_THRESHOLD = 1e9
45
+
46
+ _RESERVED_SESSION_KEYS: Mapping[str, str] = {
47
+ "connector": "the connector is built by clientwright; tune it via config or the 'connector' slot",
48
+ "connector_owner": "clientwright owns the connector lifecycle",
49
+ "middlewares": "middlewares belong to clientwright; the engine seam lives there",
50
+ "trace_configs": "trace_configs belong to clientwright (bypass sentinel and conn metrics)",
51
+ "timeout": "use ClientConfig.timeout",
52
+ "base_url": "use ClientConfig.base_url",
53
+ "headers": "use ClientConfig.headers",
54
+ "proxy": "use ClientConfig.proxy",
55
+ }
56
+
57
+ _RESERVED_CONNECTOR_KEYS: Mapping[str, str] = {
58
+ "limit": "use ClientConfig.pool.max_connections",
59
+ "limit_per_host": "use ClientConfig.pool.max_connections_per_host",
60
+ "keepalive_timeout": "use ClientConfig.pool.keepalive_expiry",
61
+ "force_close": "use ClientConfig.pool.keepalive_expiry=None",
62
+ "ssl": "use ClientConfig.tls",
63
+ }
64
+
65
+
66
+ def _ssl_argument(config: ClientConfig) -> ssl.SSLContext | bool:
67
+ tls = config.tls
68
+ if tls.ca_bundle is None and tls.cert is None:
69
+ return bool(tls.verify)
70
+ context = ssl.create_default_context(cafile=tls.ca_bundle)
71
+ if not tls.verify:
72
+ context.check_hostname = False
73
+ context.verify_mode = ssl.CERT_NONE
74
+ if tls.cert is not None:
75
+ if isinstance(tls.cert, str):
76
+ context.load_cert_chain(tls.cert)
77
+ else:
78
+ context.load_cert_chain(*tls.cert)
79
+ return context
80
+
81
+
82
+ def _no_proxy_hosts(proxies: dict[str, str]) -> tuple[str, ...]:
83
+ raw = proxies.get("no", "")
84
+ return tuple(entry.strip() for entry in raw.split(",") if entry.strip() and entry.strip() != "*")
85
+
86
+
87
+ def _proxy_router(config: ClientConfig) -> ProxyRouter | None:
88
+ if config.proxy is None:
89
+ return None
90
+ if config.proxy.url is not None:
91
+ return ProxyRouter(config.proxy.url)
92
+ proxies = getproxies()
93
+ by_scheme: dict[str, str] = {scheme: proxies[scheme] for scheme in ("http", "https") if scheme in proxies}
94
+ if not by_scheme:
95
+ return None
96
+ return ProxyRouter(None, by_scheme, _no_proxy_hosts(proxies))
97
+
98
+
99
+ class AiohttpAdapter:
100
+ name = "aiohttp"
101
+ capabilities = CAPABILITIES
102
+ native_slots = frozenset({"session", "connector"})
103
+ reserved_keys: Mapping[str, Mapping[str, str]] = {
104
+ "session": _RESERVED_SESSION_KEYS,
105
+ "connector": _RESERVED_CONNECTOR_KEYS,
106
+ }
107
+ allowed_keys: Mapping[str, frozenset[str] | None] = {"session": None, "connector": None}
108
+
109
+ def _validated_native(self, config: ClientConfig) -> dict[str, dict[str, Any]]:
110
+ return validate_native(
111
+ config.native,
112
+ slots=self.native_slots,
113
+ reserved=self.reserved_keys,
114
+ allowed=self.allowed_keys,
115
+ signature_targets={"session": aiohttp.ClientSession, "connector": aiohttp.TCPConnector},
116
+ config_conflicts={},
117
+ )
118
+
119
+ def _compile(self, config: ClientConfig) -> CallPlan:
120
+ applied = {
121
+ Capability.TIMEOUT_CONNECT,
122
+ Capability.TIMEOUT_READ,
123
+ Capability.POOL_LIMIT_TOTAL,
124
+ Capability.KEEPALIVE,
125
+ }
126
+ if resolve(config.pool.max_connections_per_host, None) is not None:
127
+ applied.add(Capability.POOL_LIMIT_PER_HOST)
128
+ emulated = {Capability.TIMEOUT_TOTAL, Capability.TIMEOUT_ATTEMPT, Capability.DEADLINE_HARD}
129
+ if config.proxy is not None:
130
+ emulated.add(Capability.PROXY)
131
+ dropped: dict[Capability, str] = {}
132
+ if is_set(config.timeout.write) and resolve(config.timeout.write, None) is not None:
133
+ dropped[Capability.TIMEOUT_WRITE] = (
134
+ "aiohttp has no write timeout; a slow upload is bounded only by the attempt ceiling"
135
+ )
136
+ if is_set(config.timeout.pool_acquire) and resolve(config.timeout.pool_acquire, None) is not None:
137
+ dropped[Capability.TIMEOUT_POOL] = (
138
+ "aiohttp folds pool waiting into the connect phase; there is no separate pool timeout"
139
+ )
140
+ if resolve(config.pool.http2, False):
141
+ dropped[Capability.HTTP2] = "aiohttp speaks HTTP/1.1 only"
142
+ plan = compile_plan(
143
+ config,
144
+ CAPABILITIES,
145
+ native_timeout_defaults=_NATIVE_TIMEOUT_DEFAULTS,
146
+ applied_natively=frozenset(applied),
147
+ emulated=frozenset(emulated),
148
+ dropped=dropped,
149
+ )
150
+ plan.report.enforce(config.on_unsupported)
151
+ return plan
152
+
153
+ def _connector(self, config: ClientConfig, native_connector: dict[str, Any]) -> aiohttp.TCPConnector:
154
+ limit = resolve(config.pool.max_connections, 100)
155
+ per_host = resolve(config.pool.max_connections_per_host, None)
156
+ keepalive = resolve(config.pool.keepalive_expiry, 15.0)
157
+ kwargs: dict[str, Any] = {
158
+ "limit": 0 if limit is None else limit,
159
+ "limit_per_host": 0 if per_host is None else per_host,
160
+ "ssl": _ssl_argument(config),
161
+ }
162
+ if keepalive is None:
163
+ kwargs["force_close"] = True
164
+ else:
165
+ kwargs["keepalive_timeout"] = keepalive
166
+ kwargs.update(native_connector)
167
+ return aiohttp.TCPConnector(**kwargs)
168
+
169
+ def build_async(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
170
+ native = self._validated_native(config)
171
+ telemetry = ClientTelemetry(
172
+ service=config.service_name,
173
+ adapter=self.name,
174
+ seam=CAPABILITIES.seam,
175
+ config=config.observability,
176
+ metrics=deps.metrics,
177
+ tracer=deps.tracer,
178
+ )
179
+ runtime = deps.runtime or ClientRuntime.for_config(
180
+ config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
181
+ )
182
+ plan = self._compile(config)
183
+ base = base_timeouts(config.timeout, _NATIVE_TIMEOUT_DEFAULTS)
184
+ engine = AsyncAttemptEngine(
185
+ plan=plan,
186
+ runtime=runtime,
187
+ telemetry=telemetry,
188
+ normalizer=AsyncAiohttpNormalizer(),
189
+ deps=deps,
190
+ translate=translate_call_error,
191
+ )
192
+ middleware = EngineMiddleware(engine, _proxy_router(config))
193
+ # total=None ON PURPOSE: aiohttp's total timer would wrap the engine's
194
+ # whole retry loop, sleeps included. The engine's deadline is the total.
195
+ client_timeout = aiohttp.ClientTimeout(
196
+ total=None,
197
+ connect=base.connect,
198
+ sock_read=base.read,
199
+ ceil_threshold=_NO_CEIL_THRESHOLD,
200
+ )
201
+ session_kwargs: dict[str, Any] = dict(native.get("session", {}))
202
+ if config.base_url is not None:
203
+ session_kwargs["base_url"] = config.base_url
204
+ session = aiohttp.ClientSession(
205
+ connector=self._connector(config, native.get("connector", {})),
206
+ timeout=client_timeout,
207
+ middlewares=(middleware,),
208
+ trace_configs=[build_trace_config(telemetry, runtime.clock)],
209
+ **session_kwargs,
210
+ )
211
+ try:
212
+ # The hidden one-shot idempotent retry would multiply our attempt
213
+ # count outside the metrics; the name is listed in ClientSession.ATTRS.
214
+ session._retry_connection = False
215
+ except AttributeError: # pragma: no cover - future aiohttp may drop the attribute
216
+ pass
217
+ handle: ClientHandle[Any] = ClientHandle(
218
+ client=session,
219
+ adapter=self.name,
220
+ capabilities=CAPABILITIES,
221
+ report=plan.report,
222
+ runtime=runtime,
223
+ plan=plan,
224
+ aclose=session.close,
225
+ )
226
+ register_handle(session, handle)
227
+ return handle
228
+
229
+ def build_sync(self, config: ClientConfig, deps: AdapterDeps) -> ClientHandle[Any]:
230
+ raise UnsupportedCapabilityError(
231
+ "aiohttp is async-only: there is no sync client to build; use build() or build_handle(), "
232
+ "or pick an adapter with a sync flavor (httpx, requests, urllib3)"
233
+ )
234
+
235
+
236
+ __all__ = ["AiohttpAdapter"]
@@ -0,0 +1,81 @@
1
+ """aiohttp capability declaration. Zero-dependency: never imports aiohttp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...core.capabilities import (
6
+ AdapterCapabilities,
7
+ Capability,
8
+ DurationBoundary,
9
+ SeamGranularity,
10
+ Support,
11
+ )
12
+ from ...core.model import FailureKind
13
+
14
+ CAPABILITIES = AdapterCapabilities(
15
+ adapter="aiohttp",
16
+ seam="middleware",
17
+ granularity=SeamGranularity.HOP,
18
+ boundary=DurationBoundary.HEADERS,
19
+ support={
20
+ Capability.TIMEOUT_TOTAL: Support.EMULATED,
21
+ Capability.TIMEOUT_ATTEMPT: Support.EMULATED,
22
+ Capability.TIMEOUT_CONNECT: Support.NATIVE,
23
+ Capability.TIMEOUT_READ: Support.NATIVE,
24
+ Capability.TIMEOUT_WRITE: Support.ABSENT,
25
+ Capability.TIMEOUT_POOL: Support.ABSENT,
26
+ Capability.DEADLINE_HARD: Support.EMULATED,
27
+ Capability.POOL_LIMIT_TOTAL: Support.NATIVE,
28
+ Capability.POOL_LIMIT_PER_HOST: Support.NATIVE,
29
+ Capability.KEEPALIVE: Support.NATIVE,
30
+ Capability.POOL_METRICS: Support.NATIVE,
31
+ Capability.CONN_METRICS: Support.NATIVE,
32
+ Capability.REDIRECTS_OWNABLE: Support.NATIVE,
33
+ Capability.NATIVE_RETRY_DISABLEABLE: Support.DEGRADED,
34
+ Capability.PER_CALL_OPTIONS: Support.EMULATED,
35
+ Capability.RETROFIT: Support.ABSENT,
36
+ Capability.EXACT_NATIVE_TYPE: Support.NATIVE,
37
+ Capability.BALANCER: Support.ABSENT,
38
+ Capability.HTTP2: Support.ABSENT,
39
+ Capability.HTTP3: Support.ABSENT,
40
+ Capability.PROXY: Support.EMULATED,
41
+ },
42
+ emits=frozenset(
43
+ {
44
+ FailureKind.CONNECT_TIMEOUT,
45
+ FailureKind.READ_TIMEOUT,
46
+ FailureKind.TOTAL_TIMEOUT,
47
+ FailureKind.CONNECT_ERROR,
48
+ FailureKind.DNS_ERROR,
49
+ FailureKind.TLS_ERROR,
50
+ FailureKind.PROTOCOL_ERROR,
51
+ FailureKind.DISCONNECTED,
52
+ FailureKind.STATUS,
53
+ FailureKind.CANCELLED,
54
+ FailureKind.CIRCUIT_OPEN,
55
+ FailureKind.UNKNOWN,
56
+ }
57
+ ),
58
+ collapses={
59
+ FailureKind.POOL_TIMEOUT: FailureKind.CONNECT_TIMEOUT,
60
+ FailureKind.WRITE_TIMEOUT: FailureKind.TOTAL_TIMEOUT,
61
+ },
62
+ notes={
63
+ "async_only": "aiohttp has no sync client; build_sync raises. The session must be built inside a running loop.",
64
+ "timeout_write": "aiohttp has no write timeout; a slow upload is bounded only by the attempt ceiling.",
65
+ "timeout_pool": "Pool waiting is folded into the connect phase; on_connection_queued metrics expose it.",
66
+ "per_call_options": "No request extensions; route/idempotency travel via the call_options() context manager.",
67
+ "native_retry": (
68
+ "aiohttp's one-shot idempotent retry on a dropped keep-alive connection is silenced via the private "
69
+ "_retry_connection attribute; DEGRADED because the seam is not a public API."
70
+ ),
71
+ "seam_bypass": (
72
+ "Per-request middlewares=() REPLACES session middlewares and bypasses the engine entirely; TraceConfig "
73
+ "cannot be overridden per request and emits the uninstrumented_calls sentinel when that happens."
74
+ ),
75
+ "ceil_threshold": "ClientTimeout.ceil_threshold is raised so aiohttp never ceils deadlines to whole seconds.",
76
+ "body_duration": "The middleware returns at headers; body read is not instrumented (no body_duration metric).",
77
+ "max_keepalive": "aiohttp has no cap on the NUMBER of keep-alive connections; pool.max_keepalive is ignored.",
78
+ },
79
+ )
80
+
81
+ __all__ = ["CAPABILITIES"]
@@ -0,0 +1,59 @@
1
+ """aiohttp exception -> FailureKind classification.
2
+
3
+ aiohttp folds pool waiting, DNS, TCP and TLS into one connect phase, so there
4
+ is no POOL_TIMEOUT here (declared as a collapse into CONNECT_TIMEOUT). DNS
5
+ failures, unlike httpx, ARE distinguishable natively.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import ssl
12
+
13
+ from ...core.model import FailureKind
14
+ from ._imports import aiohttp
15
+
16
+
17
+ def _has_ssl_cause(exc: BaseException) -> bool:
18
+ seen: set[int] = set()
19
+ current: BaseException | None = exc
20
+ while current is not None and id(current) not in seen:
21
+ seen.add(id(current))
22
+ if isinstance(current, (ssl.SSLError, ssl.CertificateError)):
23
+ return True
24
+ current = current.__cause__ or current.__context__
25
+ return False
26
+
27
+
28
+ def classify_error(exc: BaseException) -> FailureKind:
29
+ if isinstance(exc, asyncio.CancelledError):
30
+ return FailureKind.CANCELLED
31
+ if isinstance(exc, aiohttp.ConnectionTimeoutError):
32
+ return FailureKind.CONNECT_TIMEOUT
33
+ if isinstance(exc, aiohttp.SocketTimeoutError):
34
+ return FailureKind.READ_TIMEOUT
35
+ if isinstance(exc, aiohttp.ServerTimeoutError):
36
+ return FailureKind.CONNECT_TIMEOUT
37
+ if isinstance(exc, aiohttp.ClientSSLError):
38
+ # Covers ClientConnectorCertificateError and ClientConnectorSSLError;
39
+ # must sit ABOVE ClientConnectorError, which it subclasses in
40
+ # aiohttp>=3.14 - below it the branch is dead code.
41
+ return FailureKind.TLS_ERROR
42
+ if isinstance(exc, aiohttp.ClientConnectorDNSError):
43
+ return FailureKind.DNS_ERROR
44
+ if isinstance(exc, aiohttp.ClientConnectorError):
45
+ return FailureKind.TLS_ERROR if _has_ssl_cause(exc) else FailureKind.CONNECT_ERROR
46
+ if isinstance(exc, aiohttp.ServerDisconnectedError):
47
+ return FailureKind.DISCONNECTED
48
+ if isinstance(exc, aiohttp.ClientOSError):
49
+ return FailureKind.CONNECT_ERROR
50
+ if isinstance(exc, aiohttp.ClientPayloadError):
51
+ return FailureKind.BODY_ERROR
52
+ if isinstance(exc, aiohttp.ClientResponseError):
53
+ return FailureKind.PROTOCOL_ERROR
54
+ if isinstance(exc, TimeoutError):
55
+ return FailureKind.TOTAL_TIMEOUT
56
+ return FailureKind.UNKNOWN
57
+
58
+
59
+ __all__ = ["classify_error"]
@@ -0,0 +1,57 @@
1
+ """Kernel errors dual-inherited into the aiohttp family.
2
+
3
+ A user's ``except aiohttp.ClientError`` (or ``except asyncio.TimeoutError``)
4
+ keeps working when clientwright raises on its own authority.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
10
+ from ._imports import aiohttp
11
+
12
+
13
+ class AiohttpCircuitOpenError(CircuitOpenError, aiohttp.ClientError):
14
+ """Circuit open, catchable as aiohttp.ClientError."""
15
+
16
+
17
+ class AiohttpDeadlineExceededError(DeadlineExceededError, aiohttp.ServerTimeoutError):
18
+ """Total deadline exhausted, catchable as asyncio.TimeoutError and aiohttp.ClientError."""
19
+
20
+
21
+ class AiohttpTooManyRedirectsError(TooManyRedirectsError, aiohttp.TooManyRedirects):
22
+ """Owned redirect limit exceeded, catchable as aiohttp.TooManyRedirects.
23
+
24
+ ``ClientResponseError.__init__`` demands request_info/history positionally,
25
+ which breaks the cooperative super chain - so this class initializes both
26
+ parents by hand and pins the aiohttp-side attributes its ``__str__`` needs.
27
+ """
28
+
29
+ def __init__(self, hops: int) -> None:
30
+ Exception.__init__(self, f"Exceeded {hops} redirect hops")
31
+ self.hops = hops
32
+ self.request_info = None # type: ignore[assignment]
33
+ self.history = ()
34
+ self.status = 0
35
+ self.message = f"Exceeded {hops} redirect hops"
36
+ self.headers = None
37
+
38
+ def __str__(self) -> str:
39
+ return self.message
40
+
41
+
42
+ def translate_call_error(error: CallError) -> BaseException:
43
+ if isinstance(error, CircuitOpenError):
44
+ return AiohttpCircuitOpenError(error.key, error.retry_after)
45
+ if isinstance(error, DeadlineExceededError):
46
+ return AiohttpDeadlineExceededError(error.total)
47
+ if isinstance(error, TooManyRedirectsError):
48
+ return AiohttpTooManyRedirectsError(error.hops)
49
+ return error
50
+
51
+
52
+ __all__ = [
53
+ "AiohttpCircuitOpenError",
54
+ "AiohttpDeadlineExceededError",
55
+ "AiohttpTooManyRedirectsError",
56
+ "translate_call_error",
57
+ ]
@@ -0,0 +1,103 @@
1
+ """The engine-driven client middleware.
2
+
3
+ The seam sits UNDER the public API: the returned client is a genuine
4
+ ``aiohttp.ClientSession`` and every request that flows through it passes the
5
+ engine - unless the caller writes ``middlewares=()``, which aiohttp treats as
6
+ a full replacement; the TraceConfig sentinel counts those bypasses.
7
+
8
+ The middleware is a long-lived hashable object on purpose: aiohttp caches the
9
+ built middleware chain per ``(handler, middlewares)`` key, so a per-request
10
+ closure would thrash that LRU on every call.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Awaitable, Callable, Mapping
16
+
17
+ from ...core.contracts.message import RequestView
18
+ from ...core.engine.aio import AsyncAttemptEngine
19
+ from ._imports import URL, aiohttp
20
+ from .trace import ENGINE_ACTIVE
21
+ from .views import AiohttpRequestView
22
+
23
+ type ClientHandler = Callable[[aiohttp.ClientRequest], Awaitable[aiohttp.ClientResponse]]
24
+
25
+
26
+ class ProxyRouter:
27
+ """Per-hop proxy choice: explicit URL, or environment proxies by scheme."""
28
+
29
+ __slots__ = ("_by_scheme", "_explicit", "_no_proxy_hosts")
30
+
31
+ def __init__(
32
+ self,
33
+ explicit: str | None,
34
+ by_scheme: Mapping[str, str] | None = None,
35
+ no_proxy_hosts: tuple[str, ...] = (),
36
+ ) -> None:
37
+ self._explicit = URL(explicit) if explicit is not None else None
38
+ self._by_scheme = {scheme: URL(url) for scheme, url in (by_scheme or {}).items()}
39
+ self._no_proxy_hosts = no_proxy_hosts
40
+
41
+ def _bypasses(self, host: str) -> bool:
42
+ for entry in self._no_proxy_hosts:
43
+ candidate = entry.lstrip(".")
44
+ if host == candidate or host.endswith("." + candidate):
45
+ return True
46
+ return False
47
+
48
+ def apply(self, request: aiohttp.ClientRequest) -> None:
49
+ if self._explicit is not None:
50
+ request.proxy = self._explicit
51
+ return
52
+ host = request.url.host or ""
53
+ if self._bypasses(host):
54
+ request.proxy = None
55
+ return
56
+ request.proxy = self._by_scheme.get(request.url.scheme)
57
+
58
+
59
+ async def _clear_body(request: aiohttp.ClientRequest) -> None:
60
+ """Drop the body the aiohttp way: ``update_body`` closes the old payload."""
61
+ update = getattr(request, "update_body", None)
62
+ if update is None: # pragma: no cover - update_body exists on aiohttp>=3.12
63
+ request.body = b""
64
+ return
65
+ result = update(b"")
66
+ if result is not None and hasattr(result, "__await__"):
67
+ await result
68
+
69
+
70
+ class EngineMiddleware:
71
+ """One logical call per invocation; the engine owns retries, redirects, deadline."""
72
+
73
+ __slots__ = ("_engine", "_proxy")
74
+
75
+ def __init__(self, engine: AsyncAttemptEngine, proxy: ProxyRouter | None = None) -> None:
76
+ self._engine = engine
77
+ self._proxy = proxy
78
+
79
+ async def _send(self, view: RequestView, handler: ClientHandler) -> aiohttp.ClientResponse:
80
+ native = view.native
81
+ if isinstance(view, AiohttpRequestView) and view.pending_empty_body:
82
+ await _clear_body(native)
83
+ view.pending_empty_body = False
84
+ if self._proxy is not None:
85
+ # Inside the engine seam: every owned-redirect hop re-routes.
86
+ self._proxy.apply(native)
87
+ return await handler(native)
88
+
89
+ async def __call__(self, request: aiohttp.ClientRequest, handler: ClientHandler) -> aiohttp.ClientResponse:
90
+ token = ENGINE_ACTIVE.set(True)
91
+ try:
92
+
93
+ async def send(view: RequestView) -> aiohttp.ClientResponse:
94
+ return await self._send(view, handler)
95
+
96
+ response = await self._engine.run(request, send)
97
+ assert isinstance(response, aiohttp.ClientResponse)
98
+ return response
99
+ finally:
100
+ ENGINE_ACTIVE.reset(token)
101
+
102
+
103
+ __all__ = ["EngineMiddleware", "ProxyRouter"]