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,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"]
|