httpware 0.8.1__tar.gz → 0.8.2__tar.gz
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.
- {httpware-0.8.1 → httpware-0.8.2}/PKG-INFO +1 -1
- {httpware-0.8.1 → httpware-0.8.2}/pyproject.toml +1 -1
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/client.py +44 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/resilience/bulkhead.py +37 -1
- {httpware-0.8.1 → httpware-0.8.2}/README.md +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/_internal/status.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/errors.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.8.1 → httpware-0.8.2}/src/httpware/py.typed +0 -0
|
@@ -159,6 +159,28 @@ class AsyncClient:
|
|
|
159
159
|
except Exception as exc:
|
|
160
160
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
161
161
|
|
|
162
|
+
async def send_with_response(
|
|
163
|
+
self,
|
|
164
|
+
request: httpx2.Request,
|
|
165
|
+
*,
|
|
166
|
+
response_model: type[T],
|
|
167
|
+
) -> tuple[httpx2.Response, T]:
|
|
168
|
+
"""Send `request` through the middleware chain; return (response, decoded).
|
|
169
|
+
|
|
170
|
+
Use this when you need response metadata (headers, status, request URL)
|
|
171
|
+
AND a typed body — most commonly for Link-header pagination. For the
|
|
172
|
+
body-only case, prefer ``send(request, response_model=...)``.
|
|
173
|
+
|
|
174
|
+
Not for streaming responses — decodes ``response.content``, which
|
|
175
|
+
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
176
|
+
"""
|
|
177
|
+
response = await self._dispatch(request)
|
|
178
|
+
try:
|
|
179
|
+
decoded = self._decoder.decode(response.content, response_model)
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
182
|
+
return response, decoded
|
|
183
|
+
|
|
162
184
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
163
185
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
164
186
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
@@ -879,6 +901,28 @@ class Client:
|
|
|
879
901
|
except Exception as exc:
|
|
880
902
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
881
903
|
|
|
904
|
+
def send_with_response(
|
|
905
|
+
self,
|
|
906
|
+
request: httpx2.Request,
|
|
907
|
+
*,
|
|
908
|
+
response_model: type[T],
|
|
909
|
+
) -> tuple[httpx2.Response, T]:
|
|
910
|
+
"""Send `request` through the middleware chain; return (response, decoded).
|
|
911
|
+
|
|
912
|
+
Use this when you need response metadata (headers, status, request URL)
|
|
913
|
+
AND a typed body — most commonly for Link-header pagination. For the
|
|
914
|
+
body-only case, prefer ``send(request, response_model=...)``.
|
|
915
|
+
|
|
916
|
+
Not for streaming responses — decodes ``response.content``, which
|
|
917
|
+
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
918
|
+
"""
|
|
919
|
+
response = self._dispatch(request)
|
|
920
|
+
try:
|
|
921
|
+
decoded = self._decoder.decode(response.content, response_model)
|
|
922
|
+
except Exception as exc:
|
|
923
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
924
|
+
return response, decoded
|
|
925
|
+
|
|
882
926
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
883
927
|
"""Delegate request construction to the wrapped httpx2.Client."""
|
|
884
928
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
@@ -9,6 +9,13 @@ all release deterministically.
|
|
|
9
9
|
|
|
10
10
|
AsyncBulkhead is the sharable unit — pass the same instance to multiple
|
|
11
11
|
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
|
|
12
|
+
|
|
13
|
+
AsyncBulkhead is single-event-loop: the underlying asyncio.Semaphore binds
|
|
14
|
+
to whichever loop first awaits it, and cross-loop wake-ups are not thread
|
|
15
|
+
safe. A single instance acquired from a second event loop (e.g. another
|
|
16
|
+
thread running asyncio.run) raises RuntimeError on entry rather than
|
|
17
|
+
deadlocking silently. To cap a sync+async or cross-thread workload, use
|
|
18
|
+
a Bulkhead and an AsyncBulkhead with matching max_concurrent.
|
|
12
19
|
"""
|
|
13
20
|
|
|
14
21
|
import asyncio
|
|
@@ -24,6 +31,11 @@ from httpware.middleware import AsyncNext, Next
|
|
|
24
31
|
|
|
25
32
|
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
|
|
26
33
|
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
|
|
34
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG = (
|
|
35
|
+
"AsyncBulkhead is bound to a single event loop. First seen on {first!r}; "
|
|
36
|
+
"current request is on {current!r}. Use one AsyncBulkhead per loop; "
|
|
37
|
+
"cross-thread sharing requires the sync Bulkhead primitive."
|
|
38
|
+
)
|
|
27
39
|
|
|
28
40
|
_LOGGER = logging.getLogger("httpware.bulkhead")
|
|
29
41
|
|
|
@@ -42,7 +54,8 @@ class AsyncBulkhead:
|
|
|
42
54
|
Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
|
|
43
55
|
``>= 0`` (or ``None``).
|
|
44
56
|
|
|
45
|
-
See the module docstring for the algorithm
|
|
57
|
+
See the module docstring for the algorithm, middleware-ordering guidance,
|
|
58
|
+
and the single-event-loop constraint.
|
|
46
59
|
|
|
47
60
|
"""
|
|
48
61
|
|
|
@@ -59,9 +72,32 @@ class AsyncBulkhead:
|
|
|
59
72
|
self._max_concurrent = max_concurrent
|
|
60
73
|
self._acquire_timeout = acquire_timeout
|
|
61
74
|
self._sem = asyncio.Semaphore(max_concurrent)
|
|
75
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
76
|
+
self._loop_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def _check_loop(self) -> None:
|
|
79
|
+
current = asyncio.get_running_loop()
|
|
80
|
+
cached = self._loop
|
|
81
|
+
if cached is current:
|
|
82
|
+
return
|
|
83
|
+
if cached is not None:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=cached, current=current),
|
|
86
|
+
)
|
|
87
|
+
with self._loop_lock:
|
|
88
|
+
if self._loop is None:
|
|
89
|
+
self._loop = current
|
|
90
|
+
# pragma below: inner double-check-with-lock race arm; only
|
|
91
|
+
# reachable when two threads simultaneously pass the outer
|
|
92
|
+
# cached-loop check, which single-threaded tests can't trigger.
|
|
93
|
+
elif self._loop is not current: # pragma: no cover
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=self._loop, current=current),
|
|
96
|
+
)
|
|
62
97
|
|
|
63
98
|
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
64
99
|
"""Acquire a slot (bounded by acquire_timeout), invoke next, release."""
|
|
100
|
+
self._check_loop()
|
|
65
101
|
try:
|
|
66
102
|
if self._acquire_timeout is None:
|
|
67
103
|
await self._sem.acquire()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|