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,201 @@
|
|
|
1
|
+
"""Fault-injecting HTTP origin on the standard library, for conformance tests.
|
|
2
|
+
|
|
3
|
+
Routes:
|
|
4
|
+
- ``/echo`` 200 with a small JSON body (method, path, headers echo)
|
|
5
|
+
- ``/status/{code}`` responds with that status
|
|
6
|
+
- ``/slow/{seconds}`` sleeps, then 200
|
|
7
|
+
- ``/redirect/{n}`` 302 chain of n hops ending at /echo
|
|
8
|
+
- ``/redirect-loop`` 302 to itself forever
|
|
9
|
+
- ``/flaky/{key}/{fails}`` first {fails} requests per key answer 503, then 200
|
|
10
|
+
- ``/retry-after/{seconds}`` 503 with a Retry-After header
|
|
11
|
+
- ``/disconnect`` closes the connection without a response
|
|
12
|
+
|
|
13
|
+
Chaos routes (mid-stream and protocol-level faults):
|
|
14
|
+
- ``/hang-body/{seconds}`` 200 announcing 10 bytes: 3 arrive, the rest after the stall
|
|
15
|
+
- ``/drop-body`` 200 announcing 10 bytes but the connection dies after 3
|
|
16
|
+
- ``/garbage`` raw non-HTTP bytes instead of a status line
|
|
17
|
+
- ``/reset`` hard TCP reset (SO_LINGER 0) instead of a response
|
|
18
|
+
- ``/flaky-disconnect/{key}/{fails}`` first {fails} requests per key drop the connection, then 200
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import socket
|
|
25
|
+
import struct
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from types import TracebackType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
33
|
+
protocol_version = "HTTP/1.1"
|
|
34
|
+
server: OriginServer # type: ignore[assignment]
|
|
35
|
+
|
|
36
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def _reply(self, status: int, body: bytes = b"", headers: dict[str, str] | None = None) -> None:
|
|
40
|
+
self.send_response(status)
|
|
41
|
+
for name, value in (headers or {}).items():
|
|
42
|
+
self.send_header(name, value)
|
|
43
|
+
self.send_header("Content-Length", str(len(body)))
|
|
44
|
+
self.end_headers()
|
|
45
|
+
if body and self.command != "HEAD":
|
|
46
|
+
self.wfile.write(body)
|
|
47
|
+
|
|
48
|
+
def _handle(self) -> None:
|
|
49
|
+
server = self.server
|
|
50
|
+
with server.lock:
|
|
51
|
+
server.requests.append((self.command, self.path))
|
|
52
|
+
# ALWAYS drain the request body: on keep-alive reuse, unread body bytes
|
|
53
|
+
# would be parsed as the start of the next request line.
|
|
54
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
55
|
+
request_body = self.rfile.read(length) if length else b""
|
|
56
|
+
parts = [part for part in self.path.split("?")[0].split("/") if part]
|
|
57
|
+
if not parts or parts[0] == "echo":
|
|
58
|
+
payload = {
|
|
59
|
+
"method": self.command,
|
|
60
|
+
"path": self.path,
|
|
61
|
+
"headers": {name.lower(): value for name, value in self.headers.items()},
|
|
62
|
+
"body": request_body.decode("utf-8", errors="replace"),
|
|
63
|
+
}
|
|
64
|
+
self._reply(200, json.dumps(payload).encode(), {"Content-Type": "application/json"})
|
|
65
|
+
return
|
|
66
|
+
if parts[0] == "status":
|
|
67
|
+
self._reply(int(parts[1]))
|
|
68
|
+
return
|
|
69
|
+
if parts[0] == "slow":
|
|
70
|
+
time.sleep(float(parts[1]))
|
|
71
|
+
self._reply(200, b"slow")
|
|
72
|
+
return
|
|
73
|
+
if parts[0] == "redirect":
|
|
74
|
+
hops = int(parts[1])
|
|
75
|
+
target = "/echo" if hops <= 1 else f"/redirect/{hops - 1}"
|
|
76
|
+
self._reply(302, headers={"Location": target})
|
|
77
|
+
return
|
|
78
|
+
if parts[0] == "redirect-loop":
|
|
79
|
+
self._reply(302, headers={"Location": "/redirect-loop"})
|
|
80
|
+
return
|
|
81
|
+
if parts[0] == "flaky":
|
|
82
|
+
key, fails = parts[1], int(parts[2])
|
|
83
|
+
with server.lock:
|
|
84
|
+
seen = server.flaky_counters.get(key, 0)
|
|
85
|
+
server.flaky_counters[key] = seen + 1
|
|
86
|
+
if seen < fails:
|
|
87
|
+
self._reply(503, b"flaky")
|
|
88
|
+
else:
|
|
89
|
+
self._reply(200, b"recovered")
|
|
90
|
+
return
|
|
91
|
+
if parts[0] == "retry-after":
|
|
92
|
+
self._reply(503, b"", {"Retry-After": parts[1]})
|
|
93
|
+
return
|
|
94
|
+
if parts[0] == "disconnect":
|
|
95
|
+
self.close_connection = True
|
|
96
|
+
self.connection.close()
|
|
97
|
+
return
|
|
98
|
+
if parts[0] == "hang-body":
|
|
99
|
+
body = b"0123456789"
|
|
100
|
+
self.send_response(200)
|
|
101
|
+
self.send_header("Content-Length", str(len(body)))
|
|
102
|
+
self.end_headers()
|
|
103
|
+
self.wfile.write(body[:3])
|
|
104
|
+
self.wfile.flush()
|
|
105
|
+
time.sleep(float(parts[1]))
|
|
106
|
+
try:
|
|
107
|
+
self.wfile.write(body[3:])
|
|
108
|
+
except OSError: # the client gave up mid-stall; routine for this route
|
|
109
|
+
self.close_connection = True
|
|
110
|
+
return
|
|
111
|
+
if parts[0] == "drop-body":
|
|
112
|
+
self.send_response(200)
|
|
113
|
+
self.send_header("Content-Length", "10")
|
|
114
|
+
self.end_headers()
|
|
115
|
+
self.wfile.write(b"abc")
|
|
116
|
+
self.wfile.flush()
|
|
117
|
+
self.close_connection = True
|
|
118
|
+
self.connection.close()
|
|
119
|
+
return
|
|
120
|
+
if parts[0] == "garbage":
|
|
121
|
+
self.close_connection = True
|
|
122
|
+
self.wfile.write(b"\x16\x03\x01 banana instead of a status line\r\n\r\n")
|
|
123
|
+
self.wfile.flush()
|
|
124
|
+
self.connection.close()
|
|
125
|
+
return
|
|
126
|
+
if parts[0] == "reset":
|
|
127
|
+
self.close_connection = True
|
|
128
|
+
# SO_LINGER with a zero timeout turns close() into a TCP RST.
|
|
129
|
+
self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
|
|
130
|
+
self.connection.close()
|
|
131
|
+
return
|
|
132
|
+
if parts[0] == "flaky-disconnect":
|
|
133
|
+
key, fails = parts[1], int(parts[2])
|
|
134
|
+
with server.lock:
|
|
135
|
+
seen = server.flaky_counters.get(key, 0)
|
|
136
|
+
server.flaky_counters[key] = seen + 1
|
|
137
|
+
if seen < fails:
|
|
138
|
+
self.close_connection = True
|
|
139
|
+
self.connection.close()
|
|
140
|
+
return
|
|
141
|
+
self._reply(200, b"recovered")
|
|
142
|
+
return
|
|
143
|
+
self._reply(404)
|
|
144
|
+
|
|
145
|
+
def do_GET(self) -> None:
|
|
146
|
+
self._handle()
|
|
147
|
+
|
|
148
|
+
def do_POST(self) -> None:
|
|
149
|
+
self._handle()
|
|
150
|
+
|
|
151
|
+
def do_PUT(self) -> None:
|
|
152
|
+
self._handle()
|
|
153
|
+
|
|
154
|
+
def do_HEAD(self) -> None:
|
|
155
|
+
self._handle()
|
|
156
|
+
|
|
157
|
+
def do_DELETE(self) -> None:
|
|
158
|
+
self._handle()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class OriginServer(ThreadingHTTPServer):
|
|
162
|
+
"""Context-managed origin bound to an ephemeral localhost port."""
|
|
163
|
+
|
|
164
|
+
daemon_threads = True
|
|
165
|
+
|
|
166
|
+
def handle_error(self, request: object, client_address: object) -> None:
|
|
167
|
+
"""Silence per-connection tracebacks: aborted clients are the point here."""
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
super().__init__(("127.0.0.1", 0), _Handler)
|
|
172
|
+
self.lock = threading.Lock()
|
|
173
|
+
self.requests: list[tuple[str, str]] = []
|
|
174
|
+
self.flaky_counters: dict[str, int] = {}
|
|
175
|
+
self._thread = threading.Thread(target=self.serve_forever, daemon=True)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def url(self) -> str:
|
|
179
|
+
raw_host, port = self.server_address[0], self.server_address[1]
|
|
180
|
+
host = raw_host.decode() if isinstance(raw_host, bytes) else raw_host
|
|
181
|
+
return f"http://{host}:{port}"
|
|
182
|
+
|
|
183
|
+
def request_count(self, path_prefix: str) -> int:
|
|
184
|
+
with self.lock:
|
|
185
|
+
return sum(1 for _, path in self.requests if path.startswith(path_prefix))
|
|
186
|
+
|
|
187
|
+
def __enter__(self) -> OriginServer:
|
|
188
|
+
self._thread.start()
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def __exit__(
|
|
192
|
+
self,
|
|
193
|
+
exc_type: type[BaseException] | None,
|
|
194
|
+
exc: BaseException | None,
|
|
195
|
+
tb: TracebackType | None,
|
|
196
|
+
) -> None:
|
|
197
|
+
self.shutdown()
|
|
198
|
+
self.server_close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["OriginServer"]
|
clientwright/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clientwright
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP client platform: one resilience and observability core with adapters for httpx, aiohttp, requests, urllib3 and friends
|
|
5
|
+
Project-URL: Repository, https://github.com/bedrock-python/clientwright
|
|
6
|
+
Project-URL: Documentation, https://bedrock-python.github.io/clientwright/
|
|
7
|
+
Project-URL: Changelog, https://github.com/bedrock-python/clientwright/blob/master/CHANGELOG.md
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/bedrock-python/clientwright/issues
|
|
9
|
+
Author-email: Alex Shalaev <shalaevad.alexey@gmail.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aiohttp,circuit-breaker,deadline,http,http-client,httpx,observability,opentelemetry,prometheus,requests,resilience,retry,timeout,urllib3
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Provides-Extra: aiohttp
|
|
26
|
+
Requires-Dist: aiohttp<4,>=3.12; extra == 'aiohttp'
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: aiohttp<4,>=3.12; extra == 'all'
|
|
29
|
+
Requires-Dist: deadline-budget<1,>=0.1; extra == 'all'
|
|
30
|
+
Requires-Dist: dishka>=1.4; extra == 'all'
|
|
31
|
+
Requires-Dist: httpx2<3,>=2; extra == 'all'
|
|
32
|
+
Requires-Dist: httpx<1,>=0.28; extra == 'all'
|
|
33
|
+
Requires-Dist: opentelemetry-api>=1.26; extra == 'all'
|
|
34
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'all'
|
|
35
|
+
Requires-Dist: requests<3,>=2.32; extra == 'all'
|
|
36
|
+
Requires-Dist: urllib3<3,>=2.2; extra == 'all'
|
|
37
|
+
Provides-Extra: deadline
|
|
38
|
+
Requires-Dist: deadline-budget<1,>=0.1; extra == 'deadline'
|
|
39
|
+
Provides-Extra: dishka
|
|
40
|
+
Requires-Dist: dishka>=1.4; extra == 'dishka'
|
|
41
|
+
Provides-Extra: httpx
|
|
42
|
+
Requires-Dist: httpx<1,>=0.28; extra == 'httpx'
|
|
43
|
+
Provides-Extra: httpx2
|
|
44
|
+
Requires-Dist: httpx2<3,>=2; extra == 'httpx2'
|
|
45
|
+
Provides-Extra: metrics
|
|
46
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'metrics'
|
|
47
|
+
Provides-Extra: observability
|
|
48
|
+
Requires-Dist: opentelemetry-api>=1.26; extra == 'observability'
|
|
49
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'observability'
|
|
50
|
+
Provides-Extra: requests
|
|
51
|
+
Requires-Dist: requests<3,>=2.32; extra == 'requests'
|
|
52
|
+
Provides-Extra: tracing
|
|
53
|
+
Requires-Dist: opentelemetry-api>=1.26; extra == 'tracing'
|
|
54
|
+
Provides-Extra: urllib3
|
|
55
|
+
Requires-Dist: urllib3<3,>=2.2; extra == 'urllib3'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# clientwright
|
|
59
|
+
|
|
60
|
+
[](https://pypi.org/project/clientwright/)
|
|
61
|
+
[](https://pypi.org/project/clientwright/)
|
|
62
|
+
[](LICENSE)
|
|
63
|
+
[](https://github.com/bedrock-python/clientwright/actions/workflows/ci.yml)
|
|
64
|
+
[](https://codecov.io/gh/bedrock-python/clientwright)
|
|
65
|
+
[](https://bedrock-python.github.io/clientwright/)
|
|
66
|
+
|
|
67
|
+
One resilience and observability core, many HTTP clients.
|
|
68
|
+
|
|
69
|
+
**Documentation: <https://bedrock-python.github.io/clientwright/>** — a guided
|
|
70
|
+
tour from first client to writing your own adapter; this README is the elevator
|
|
71
|
+
version.
|
|
72
|
+
|
|
73
|
+
`clientwright` is to HTTP clients what [servicewright](https://github.com/bedrock-python/servicewright)
|
|
74
|
+
is to services: a zero-dependency kernel of policies (retries, circuit breaking, deadlines,
|
|
75
|
+
owned redirects, telemetry) plus adapters that wire it UNDER the public API of popular HTTP
|
|
76
|
+
libraries. You get back the **real native client** - a genuine `httpx.AsyncClient`, not a
|
|
77
|
+
wrapper - with the whole machinery already working inside it.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import httpx
|
|
81
|
+
from clientwright import ClientConfig, TimeoutConfig, build, inspect
|
|
82
|
+
|
|
83
|
+
config = ClientConfig(
|
|
84
|
+
service_name="google-oauth",
|
|
85
|
+
base_url="https://oauth2.googleapis.com",
|
|
86
|
+
timeout=TimeoutConfig(total=10.0, connect=2.0),
|
|
87
|
+
)
|
|
88
|
+
client: httpx.AsyncClient = build("httpx", config)
|
|
89
|
+
assert type(client) is httpx.AsyncClient # not a subclass, not a wrapper
|
|
90
|
+
|
|
91
|
+
response = await client.post("/token", data={...}) # retries, breaker, deadline,
|
|
92
|
+
# metrics and spans already apply
|
|
93
|
+
|
|
94
|
+
handle = inspect(client) # what actually got applied
|
|
95
|
+
print(handle.report.dropped) # config the adapter could not express
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Why
|
|
99
|
+
|
|
100
|
+
- **Instrumentation under the public API.** The seam sits in the transport
|
|
101
|
+
(httpx), not above the client - taking the raw native client never loses
|
|
102
|
+
logging, metrics, retries or the circuit breaker.
|
|
103
|
+
- **One engine, not N reimplementations.** A single attempt loop owns retries,
|
|
104
|
+
redirects, deadlines and telemetry for every adapter; adapters only translate
|
|
105
|
+
messages and send.
|
|
106
|
+
- **Capability-honest.** Adapters declare what they can and cannot express
|
|
107
|
+
(`AdapterCapabilities`); anything you configured that cannot be honored lands
|
|
108
|
+
in a `ConfigApplicationReport` - and fails the build under
|
|
109
|
+
`on_unsupported="strict"` instead of silently lying.
|
|
110
|
+
- **Owned redirects.** The engine follows redirects itself, so one logical call
|
|
111
|
+
has ONE deadline, ONE retry budget and ONE breaker signal regardless of hops.
|
|
112
|
+
- **Total deadline everywhere.** httpx has no wall-clock timeout; clientwright
|
|
113
|
+
enforces one across attempts, backoff sleeps and redirect hops (hard
|
|
114
|
+
cancellation on async, phase clamping on sync).
|
|
115
|
+
- **Frozen telemetry schema.** `http_client_*` metric families with mandatory
|
|
116
|
+
`adapter`/`seam` labels - backend divergence is observable on a dashboard,
|
|
117
|
+
not buried in a README.
|
|
118
|
+
- **Batteries optional.** `pip install clientwright` has zero dependencies;
|
|
119
|
+
adapters and observability backends are extras with lazy import guards.
|
|
120
|
+
|
|
121
|
+
## Install
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install clientwright[httpx] # httpx adapter (sync + async)
|
|
125
|
+
pip install clientwright[aiohttp] # aiohttp adapter (async)
|
|
126
|
+
pip install clientwright[httpx,metrics] # + Prometheus backend
|
|
127
|
+
pip install clientwright[httpx,tracing] # + OpenTelemetry backend
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Sync twin
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from clientwright import build_sync
|
|
134
|
+
|
|
135
|
+
client = build_sync("httpx", config) # a genuine httpx.Client
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The kernel's policies are pure synchronous functions; the same retry decision
|
|
139
|
+
code drives both the async and the sync engine.
|
|
140
|
+
|
|
141
|
+
## Retry and idempotency
|
|
142
|
+
|
|
143
|
+
Per-call channel via httpx `extensions`:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from clientwright.adapters.httpx import IDEMPOTENT_EXTENSION, ROUTE_EXTENSION
|
|
147
|
+
|
|
148
|
+
await client.post(
|
|
149
|
+
"/orders",
|
|
150
|
+
json=payload,
|
|
151
|
+
extensions={
|
|
152
|
+
ROUTE_EXTENSION: "/orders", # low-cardinality metric/breaker label
|
|
153
|
+
IDEMPOTENT_EXTENSION: True, # this POST is safe to retry
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
aiohttp has no request extensions; the same channel is a context manager:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from clientwright.adapters.aiohttp import call_options
|
|
162
|
+
|
|
163
|
+
with call_options(route="/orders", idempotent=True):
|
|
164
|
+
await session.post("/orders", json=payload)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Retries respect `Retry-After`, an origin-wide token-bucket budget (max ~10%
|
|
168
|
+
retry traffic by default), body replayability and the remaining deadline.
|
|
169
|
+
|
|
170
|
+
## DI scope
|
|
171
|
+
|
|
172
|
+
`ClientRuntime` (circuits, retry budgets, per-origin limiters) must live in
|
|
173
|
+
**APP scope** and be shared across REQUEST-scoped clients via
|
|
174
|
+
`AdapterDeps(runtime=...)` - otherwise breaker state dies with every request.
|
|
175
|
+
With `clientwright[dishka]`, `contrib.dishka.ClientwrightProvider` does both:
|
|
176
|
+
APP-scope runtime and a generator provide that closes the client in `finally`.
|
|
177
|
+
|
|
178
|
+
## Deadline budgets
|
|
179
|
+
|
|
180
|
+
With `clientwright[deadline]`, the remaining budget of the request being
|
|
181
|
+
served caps every outgoing call:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from deadline_budget import BudgetContext
|
|
185
|
+
|
|
186
|
+
from clientwright import AdapterDeps, build
|
|
187
|
+
from clientwright.contrib.deadline import AmbientDeadlineSource, use_budget
|
|
188
|
+
|
|
189
|
+
deps = AdapterDeps(deadline_source=AmbientDeadlineSource())
|
|
190
|
+
client = build("httpx", config, deps)
|
|
191
|
+
|
|
192
|
+
with use_budget(BudgetContext.create(total_seconds=5.0)):
|
|
193
|
+
await client.get("/users") # runs with what is left of those 5 seconds
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Adapters
|
|
197
|
+
|
|
198
|
+
| adapter | modes | notes |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| `httpx` | sync + async | the reference adapter; cleanest seam |
|
|
201
|
+
| `httpx2` | sync + async | Pydantic's httpx successor; identical public names |
|
|
202
|
+
| `aiohttp` | async | requires aiohttp >= 3.12 (client middleware) |
|
|
203
|
+
| `requests` | sync | closes requests' no-default-timeout hole |
|
|
204
|
+
| `urllib3` | sync | home of `RetryMode.DELEGATED` |
|
|
205
|
+
|
|
206
|
+
Third-party adapters plug in via `register_adapter("name", "module:Class", "module:CAPS")`.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
Apache-2.0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
clientwright/__init__.py,sha256=VFrgOcqolBGIkwT-joFCKq1UqyayZ7DmuJxTbTApS8k,5305
|
|
2
|
+
clientwright/__version__.py,sha256=IOEzhCbX916JIkvZr03GFY4bl9YqSkfjdqLO4qJ2FOs,50
|
|
3
|
+
clientwright/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
clientwright/adapters/__init__.py,sha256=jwgB4L8hAtVzrS3GXjOM0ZWIKUrJJzctuaB1jkiH30I,106
|
|
5
|
+
clientwright/adapters/_httpx_shared.py,sha256=RGeuEUwQXp_8dotk2zcm31DT4Bs-5PSJ2jH1WzUwB-E,31813
|
|
6
|
+
clientwright/adapters/_lazy.py,sha256=Vu6aXppMkYQgu7AF7gEcZ6-ra3ReTvEwyP_LcMNFsVk,1156
|
|
7
|
+
clientwright/adapters/aiohttp/__init__.py,sha256=7lRobBHE1Ppeg53ByfSCdIz4Kr_UgLpgha-mhQO6PKQ,1415
|
|
8
|
+
clientwright/adapters/aiohttp/_imports.py,sha256=rjOt8kd6xnjenb5Zkd6dRsiVeYi9gaBee2lGXVpx7rQ,1099
|
|
9
|
+
clientwright/adapters/aiohttp/adapter.py,sha256=2CVuTk4DnC6TzbInvBFx_edx-oJgsaPzyHzTlRIjrQY,10112
|
|
10
|
+
clientwright/adapters/aiohttp/capabilities.py,sha256=dH6QEyXNvNefVZ-Nl8tw_uhD-wJKOabyENeij2wqmsY,3538
|
|
11
|
+
clientwright/adapters/aiohttp/classify.py,sha256=QvEyus3iCwyvHqI5YGYFJipoUFWhYif5jMGQb8fHs7E,2243
|
|
12
|
+
clientwright/adapters/aiohttp/errors.py,sha256=HAizlzmKfDqbSB-eNsylOArtYCZSMXGzc6ufw9niCQ0,2034
|
|
13
|
+
clientwright/adapters/aiohttp/middleware.py,sha256=tVJCG2ZUdUFae9EbG9n4eQ9aQRXGD6Dgea5pEgmqkcQ,3832
|
|
14
|
+
clientwright/adapters/aiohttp/normalize.py,sha256=YaKasiD9ruurYtLAwCVbXiO7BTmFt1wmWoWXX8P3q0A,2390
|
|
15
|
+
clientwright/adapters/aiohttp/options.py,sha256=bXc0Qmplzo11rUknshAs-fXksFyF619CpfSd7bTACro,629
|
|
16
|
+
clientwright/adapters/aiohttp/trace.py,sha256=xiIKNNXsuHAYazLZz4JOfQz3mARL28AYrIbIjRM8HgE,4530
|
|
17
|
+
clientwright/adapters/aiohttp/views.py,sha256=diGcCDnvGYClw-zIDPjjQoCHhHb1QaM-bjrFsMYG2Xg,3921
|
|
18
|
+
clientwright/adapters/httpx/__init__.py,sha256=b9azYsEXW-cv0lk4x1dBLA2AZ87uE77IyR6NOMkSHho,1417
|
|
19
|
+
clientwright/adapters/httpx/_imports.py,sha256=WJl4eKJwsZ6fV4hS-aGxN61VJasM2hwkacCpmf-IVhc,564
|
|
20
|
+
clientwright/adapters/httpx/adapter.py,sha256=cK6giIydVuBfnmAkaG2GYlsiKhex9XZ66JwgEzT9Mok,1327
|
|
21
|
+
clientwright/adapters/httpx/capabilities.py,sha256=urnXy_rdmRiouRpqhkOTKX7DdQsy8dtOyWUgzwdoD6g,226
|
|
22
|
+
clientwright/adapters/httpx/classify.py,sha256=SdQEMJa5mr3JlAtUCZBanuXZX90PrUJImYkcP_3upb4,361
|
|
23
|
+
clientwright/adapters/httpx/errors.py,sha256=xRPd9jCJFuUDzTCLhHUqSStwSIwByie5M1lSt19wTYs,1110
|
|
24
|
+
clientwright/adapters/httpx/normalize.py,sha256=A1kzYupyfwRs60hNmQhBKCuP4saPccv_gVnFwtXrHrw,856
|
|
25
|
+
clientwright/adapters/httpx/normalize_sync.py,sha256=4OtdoPegVepOKAap9wdx0_nSuG728MKEnnnSzwsu0-A,847
|
|
26
|
+
clientwright/adapters/httpx/transport.py,sha256=0R0cCCict_0LYJAZx86-Dv8bQaOib4GUgG5UxB2vLDg,1002
|
|
27
|
+
clientwright/adapters/httpx/views.py,sha256=zRR5NzwRxj27Hs6-P1Mffh8majmLXoN2IB-ifABolH8,1304
|
|
28
|
+
clientwright/adapters/httpx2/__init__.py,sha256=bR_rs0hIxcaitrGzV9QZCfTwHW_k-wILau3LN2tzl1g,1485
|
|
29
|
+
clientwright/adapters/httpx2/_imports.py,sha256=ts2tMt9nRfvzMH-LFf1MKsAM7HIK_WqEjqsXYG5XV-k,646
|
|
30
|
+
clientwright/adapters/httpx2/adapter.py,sha256=76QP1btDc7nz_5lq3vjla0bFTGFusd7lTSziuwjrUI8,1183
|
|
31
|
+
clientwright/adapters/httpx2/capabilities.py,sha256=hM0eRx4EDHg3gE79bYrgB_VaTIDK9IMdtCkPRivGBOc,229
|
|
32
|
+
clientwright/adapters/httpx2/classify.py,sha256=Hh5uQIh4n2mTggefhQ2CA38gHBje3C3Lx2-ycmge9Vo,364
|
|
33
|
+
clientwright/adapters/httpx2/errors.py,sha256=1gHx9WLxcMHHmiO0yHnf1dkPh5qsJY2h7abPlZGTTMk,1169
|
|
34
|
+
clientwright/adapters/httpx2/normalize.py,sha256=xd7LsBfG6H2qTmWY7zeSkuRtlRhtRt_AtOC4JugxDbA,860
|
|
35
|
+
clientwright/adapters/httpx2/normalize_sync.py,sha256=NlQjrbYuyr1erhgGfcs7fpMH2aLFVOL8BqJz8OhHoxY,850
|
|
36
|
+
clientwright/adapters/httpx2/transport.py,sha256=FmL42IMVHueArMi0K0K4x3R8xaphI-MvOijbJBanp-E,789
|
|
37
|
+
clientwright/adapters/httpx2/views.py,sha256=ETB7qpu7-t5aIIdbyznaap-EHDPZUVzQSEfZU34YDVA,1217
|
|
38
|
+
clientwright/adapters/observability/__init__.py,sha256=vCDrA3ZXNAYn1A8QbISF-OQB6VEtZxJXSLBLBSSRJng,787
|
|
39
|
+
clientwright/adapters/observability/_metrics/__init__.py,sha256=4oy8sErcTaXi2KqQ6Qacg6iw6WFxJVonnF0DniZY8tw,24
|
|
40
|
+
clientwright/adapters/observability/_metrics/prometheus.py,sha256=ja3zzxCItZZhzeU77LegAtZbmraS5Tpg5wmsoy-Ramg,7257
|
|
41
|
+
clientwright/adapters/observability/_tracing/__init__.py,sha256=MoUaNFNBM2MLUNBHpt1DmkOlYpU4hAzua4hcy4XeaGY,80
|
|
42
|
+
clientwright/adapters/observability/_tracing/otel.py,sha256=kOFTMj2YaXtsQYJDdbbKbJnS4QenBsurVWjxOB24Ph0,2324
|
|
43
|
+
clientwright/adapters/requests/__init__.py,sha256=JTIoZdoMOIlwTRdsol85akQg-ojV2kfZqA4la3_2fWg,1465
|
|
44
|
+
clientwright/adapters/requests/_imports.py,sha256=rKIMjnNmzeXvjt2I7bvnaxZE73CEPboQiCoUF9Xc5go,778
|
|
45
|
+
clientwright/adapters/requests/adapter.py,sha256=Vj4H_CRq6ChxeCSAFD1Gu_SbKP7-1M6SgPPiskCz1gY,9170
|
|
46
|
+
clientwright/adapters/requests/capabilities.py,sha256=WNUy-8GpyD_-JRIdiZSho6wiDGMnY3k6ibwdwfhdZ00,3308
|
|
47
|
+
clientwright/adapters/requests/classify.py,sha256=6WUPd6uiTs8L3J20GGgvu7piBTMfhXlEXavnOIYHn8g,2435
|
|
48
|
+
clientwright/adapters/requests/errors.py,sha256=HM92Av9U76YEcQc3BqZTwM2ZXOWk_juaMqasUQb6-4k,1389
|
|
49
|
+
clientwright/adapters/requests/normalize.py,sha256=FxTHWcVrBxQSTmRKZ8vOc76zT_vJJYiyTeoEMIyxHK0,2364
|
|
50
|
+
clientwright/adapters/requests/views.py,sha256=_gXgRnAzDyxjTLskNIECoZxT7t21V_5ooxOZo12FpxE,4215
|
|
51
|
+
clientwright/adapters/urllib3/__init__.py,sha256=Hv68u5d0aeiI2yfahcZlgP8-XqjOHxo0fEW2d5UR-DI,1567
|
|
52
|
+
clientwright/adapters/urllib3/_imports.py,sha256=62Xqbk-26uaywjWYsIS-xdp1PHFLfapGONM_C3A-AK0,577
|
|
53
|
+
clientwright/adapters/urllib3/adapter.py,sha256=3O0_-rzYwZ6bxiYX1TbPVX7gMuAZzelOjIfFrilRPj8,11779
|
|
54
|
+
clientwright/adapters/urllib3/capabilities.py,sha256=NzoluBLNTo7Fo48KKsMKdTpLIPNSx2_sonSi4sDC0kA,3739
|
|
55
|
+
clientwright/adapters/urllib3/classify.py,sha256=1HDXaVYhpM9yUj_8vH7TSBf8UcnyiqIAMfeYO2f8rpw,1762
|
|
56
|
+
clientwright/adapters/urllib3/errors.py,sha256=3XPFxNlz7xe0PxCvvBHdnqu0HHdHl0r1Wf-lM48H2dk,1938
|
|
57
|
+
clientwright/adapters/urllib3/normalize.py,sha256=PBEaFH1l_Dxloex7TdI5H6lQ_4RUa8Pt8ygVWFwLeyc,1801
|
|
58
|
+
clientwright/adapters/urllib3/views.py,sha256=L_tsZ1yqR618UTzxuJJ8yDm4j7xYbdv51wyWJJ0pdaA,3893
|
|
59
|
+
clientwright/contrib/__init__.py,sha256=0rneB-yKmU7BXsNX0gJ-g6kwFOWV-XUhMREXvj-dT8k,96
|
|
60
|
+
clientwright/contrib/deadline.py,sha256=LThVPkeNQUnSvUplR2HJtL98L30RrOibM2z-LfokLZ0,3685
|
|
61
|
+
clientwright/contrib/dishka.py,sha256=rr7-R6mRRXTeg7Bj0r9IfHD25UkWHCa2weZcZCerY54,2972
|
|
62
|
+
clientwright/core/__init__.py,sha256=8SCohi1xxW-c1onlMwawmasOBhG4zWYZywziq5qf7kU,256
|
|
63
|
+
clientwright/core/capabilities.py,sha256=swJIngczmK33Iu1UMdUPezEm391769t-6Qwt905l9XM,5184
|
|
64
|
+
clientwright/core/config.py,sha256=83EPH3aZo40-OpesrC7dNnhkkR1cwUl7TMschNcsE8c,12012
|
|
65
|
+
clientwright/core/errors.py,sha256=PHihA1LzhTcnnTnD672GXjSsHxGLVP-kJQkQNY32tOM,3744
|
|
66
|
+
clientwright/core/model.py,sha256=7O_A6JDIPKPOexAAQN5aJDM2-shAqfx4M3KGlalaaAI,3863
|
|
67
|
+
clientwright/core/native.py,sha256=4ch1Min3QrX0MY1WgHJ_iO9U5tGqYc8w8WRRyfungV4,3317
|
|
68
|
+
clientwright/core/options.py,sha256=mU8Z7MTY3LORRDRBEfWa4C07M4Cs6YyPrVTPiXPS43A,1459
|
|
69
|
+
clientwright/core/plan.py,sha256=0sHnKmRaaoWCi0d70lJTEk2Ud-_8GerMVDZO9Xul2lw,6836
|
|
70
|
+
clientwright/core/registry.py,sha256=e7OHpWRrc80gtAFczZxXsCER2204w671mUDjMECbPtQ,2762
|
|
71
|
+
clientwright/core/balancer/__init__.py,sha256=8wNJ6T6IZ3r63SNeZlO1lIyzArj_3jr76uOloTX0WZM,77
|
|
72
|
+
clientwright/core/balancer/policy.py,sha256=bKSBppc30ev-_5GG0qj3eEArYC5k4AXOjJgQLORe9VM,660
|
|
73
|
+
clientwright/core/contracts/__init__.py,sha256=Igpus2dYZpm-SiPuI-Qjjoq9P6I3OZ87BFMsjnwTcq8,1259
|
|
74
|
+
clientwright/core/contracts/adapter.py,sha256=LclR2W6lNvv3du3VlOMlbP4h9HLK1VRQXJOWQUoBihU,2031
|
|
75
|
+
clientwright/core/contracts/context.py,sha256=VPTuc5jJ979DsT9mttsCRNLZI021tc4aWOxVx2PAX2Y,867
|
|
76
|
+
clientwright/core/contracts/message.py,sha256=Qj6AjWnBURpTPpOTFU6Lp1IAiT6DY03JWptKnc9GOzg,3730
|
|
77
|
+
clientwright/core/contracts/observability.py,sha256=kwmbCCvmKbswe5mOwStBGdDkD4_teaNBpvklHO2BTgw,2612
|
|
78
|
+
clientwright/core/contracts/settings.py,sha256=i91kGHulYIKj-fnjHnGecUTKi3yMrv68el5YkAyLOh8,3761
|
|
79
|
+
clientwright/core/engine/__init__.py,sha256=8R4vSMN4cKQfo7C-V2AFYAyDIyr3Oao50J2BUqj_jQo,78
|
|
80
|
+
clientwright/core/engine/aio.py,sha256=-vG9zZoeRyXhomNsQgSd4HqMGS0XK75YmPMTo_aWh5A,10840
|
|
81
|
+
clientwright/core/engine/base.py,sha256=NqQk53qI0txDt-h_1nXJDTnxHZzjUsNeKse9S-JhT0c,2120
|
|
82
|
+
clientwright/core/engine/redirects.py,sha256=ypNCBZWL0pdY2B4Jf2OId13NnVZGE1D9Ok-SlyWZ2jM,2016
|
|
83
|
+
clientwright/core/engine/suppress.py,sha256=dERDmmQiCzQSfQjHg7kJ4zJhWU9ZNHoOt57ujuzLD5s,740
|
|
84
|
+
clientwright/core/engine/sync.py,sha256=LVK2PYOkxTz4iKTngmJtxEQ4-gP_iyICqAQGvV3XEMk,10353
|
|
85
|
+
clientwright/core/policy/__init__.py,sha256=kevw6wSG1CvJ8qRl_yxhVx0U4sEtEKarXwPMggj_Ffc,78
|
|
86
|
+
clientwright/core/policy/budget.py,sha256=SwJuSUCkA_jOwloKVLS2OAAaCkTpoUlyOBVho42jz1k,2658
|
|
87
|
+
clientwright/core/policy/circuit.py,sha256=y4ghWX02xYWoOxcd3U8g85AyetWGQOkKLr18f2oRjDo,6572
|
|
88
|
+
clientwright/core/policy/concurrency.py,sha256=0s3_WZ4aCLnPeZd54NfkM94OSBpX1kbX5K3P1h7n3fs,3211
|
|
89
|
+
clientwright/core/policy/retry.py,sha256=B0Xg6wpCLSYUMvDRhSl9_BPWjiY4hWZ25MrgdC4aBcY,3057
|
|
90
|
+
clientwright/core/policy/timeout.py,sha256=SklL4FDuhM5BSVgMoAfCBnB0E2_zdInzZsj6ts_YI34,2476
|
|
91
|
+
clientwright/core/telemetry/__init__.py,sha256=qm_typj2W6PCc7br6Wx251cy8EgVx8TUZQ67qVwjIik,244
|
|
92
|
+
clientwright/core/telemetry/emitter.py,sha256=0ajLUwytqiu7cLZNmt9v8DRcwExzSn7fTY625RRZGwo,6314
|
|
93
|
+
clientwright/core/telemetry/names.py,sha256=PIvAXFdqRP30zjBq7vwij2YTw2YoR4enw0cOE1gix0g,2290
|
|
94
|
+
clientwright/core/telemetry/null.py,sha256=hk3FVKqyACqlgOJJV-PFAQc-74ZGjGqmbbHmKc9F-Jw,2133
|
|
95
|
+
clientwright/core/telemetry/redaction.py,sha256=nDlRmNVZYy7-gQHo9Uc6DwzN5qb32sk1iTjM5aEOY_4,832
|
|
96
|
+
clientwright/core/testing/__init__.py,sha256=dCuqJ2APDxwCeuOjOO4z4rMuE-ao4EofxB_3h8g942g,288
|
|
97
|
+
clientwright/core/testing/doubles.py,sha256=Xkp8NdMKscT0fAy8OBY-oagXfxir8omBex0oBIVwC9U,3379
|
|
98
|
+
clientwright/core/testing/origin.py,sha256=dyPqQEnBPaxQSCNEkaEhINqJlqWwrUNLM0CF0F_QY6k,7568
|
|
99
|
+
clientwright-0.1.0.dist-info/METADATA,sha256=cGQWD74feG9_0poIuGkM1UjZ5CSqM43xMuUL9A-XazE,8793
|
|
100
|
+
clientwright-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
101
|
+
clientwright-0.1.0.dist-info/licenses/LICENSE,sha256=HqasrACfRh7mgdbiXMVrs_X8dOCZed1b8KXY7i6Ch14,10239
|
|
102
|
+
clientwright-0.1.0.dist-info/RECORD,,
|