httpware 0.8.0__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.0 → httpware-0.8.2}/PKG-INFO +2 -2
- {httpware-0.8.0 → httpware-0.8.2}/README.md +1 -1
- {httpware-0.8.0 → httpware-0.8.2}/pyproject.toml +1 -1
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/__init__.py +2 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/client.py +53 -3
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/__init__.py +6 -1
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/errors.py +42 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/bulkhead.py +37 -1
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/status.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.8.0 → httpware-0.8.2}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: Resilience-first async HTTP client framework for Python
|
|
5
5
|
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -79,7 +79,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
79
79
|
print(response.json())
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]
|
|
82
|
+
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
83
83
|
|
|
84
84
|
```python
|
|
85
85
|
from httpware import AsyncClient
|
|
@@ -49,7 +49,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
49
49
|
print(response.json())
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]
|
|
52
|
+
Typed decoding via `response_model=` works in both worlds — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
53
53
|
|
|
54
54
|
```python
|
|
55
55
|
from httpware import AsyncClient
|
|
@@ -9,6 +9,7 @@ from httpware.errors import (
|
|
|
9
9
|
ClientError,
|
|
10
10
|
ClientStatusError,
|
|
11
11
|
ConflictError,
|
|
12
|
+
DecodeError,
|
|
12
13
|
ForbiddenError,
|
|
13
14
|
InternalServerError,
|
|
14
15
|
NetworkError,
|
|
@@ -52,6 +53,7 @@ __all__ = [
|
|
|
52
53
|
"ClientError",
|
|
53
54
|
"ClientStatusError",
|
|
54
55
|
"ConflictError",
|
|
56
|
+
"DecodeError",
|
|
55
57
|
"ForbiddenError",
|
|
56
58
|
"InternalServerError",
|
|
57
59
|
"Middleware",
|
|
@@ -16,7 +16,7 @@ from httpware._internal.status import (
|
|
|
16
16
|
_raise_on_status_error,
|
|
17
17
|
)
|
|
18
18
|
from httpware.decoders import ResponseDecoder
|
|
19
|
-
from httpware.errors import TransportError
|
|
19
|
+
from httpware.errors import DecodeError, TransportError
|
|
20
20
|
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
|
|
21
21
|
from httpware.middleware.chain import compose, compose_async
|
|
22
22
|
|
|
@@ -154,7 +154,32 @@ class AsyncClient:
|
|
|
154
154
|
response = await self._dispatch(request)
|
|
155
155
|
if response_model is None:
|
|
156
156
|
return response
|
|
157
|
-
|
|
157
|
+
try:
|
|
158
|
+
return self._decoder.decode(response.content, response_model)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
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
|
|
158
183
|
|
|
159
184
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
160
185
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
@@ -871,7 +896,32 @@ class Client:
|
|
|
871
896
|
response = self._dispatch(request)
|
|
872
897
|
if response_model is None:
|
|
873
898
|
return response
|
|
874
|
-
|
|
899
|
+
try:
|
|
900
|
+
return self._decoder.decode(response.content, response_model)
|
|
901
|
+
except Exception as exc:
|
|
902
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
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
|
|
875
925
|
|
|
876
926
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
877
927
|
"""Delegate request construction to the wrapped httpx2.Client."""
|
|
@@ -11,7 +11,12 @@ class ResponseDecoder(Protocol):
|
|
|
11
11
|
"""Structural protocol every response-body decoder satisfies."""
|
|
12
12
|
|
|
13
13
|
def decode(self, content: bytes, model: type[T]) -> T:
|
|
14
|
-
"""Decode `content` (raw response bytes) into an instance of `model`.
|
|
14
|
+
"""Decode `content` (raw response bytes) into an instance of `model`.
|
|
15
|
+
|
|
16
|
+
Any exception raised by `decode` is wrapped by `Client.send` /
|
|
17
|
+
`AsyncClient.send` into `httpware.DecodeError`; implementers do not
|
|
18
|
+
need to raise `DecodeError` directly.
|
|
19
|
+
"""
|
|
15
20
|
...
|
|
16
21
|
|
|
17
22
|
|
|
@@ -212,3 +212,45 @@ class BulkheadFullError(ClientError):
|
|
|
212
212
|
_reconstruct_bulkhead_full,
|
|
213
213
|
(type(self), self.max_concurrent, self.acquire_timeout),
|
|
214
214
|
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reconstruct_decode_error(
|
|
218
|
+
cls: "type[DecodeError]",
|
|
219
|
+
response: httpx2.Response,
|
|
220
|
+
model: type,
|
|
221
|
+
original: BaseException,
|
|
222
|
+
) -> "DecodeError":
|
|
223
|
+
return cls(response=response, model=model, original=original)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class DecodeError(ClientError):
|
|
227
|
+
"""Raised when the active ResponseDecoder failed to decode response.content.
|
|
228
|
+
|
|
229
|
+
The HTTP call itself succeeded — status was 2xx/3xx and the transport
|
|
230
|
+
delivered the body intact — but the body could not be parsed into the
|
|
231
|
+
requested response_model. Always chained from the underlying library
|
|
232
|
+
exception via ``raise ... from exc``; that exception is also exposed as
|
|
233
|
+
``self.original`` for structured handling.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
response: httpx2.Response
|
|
237
|
+
model: type
|
|
238
|
+
original: BaseException
|
|
239
|
+
|
|
240
|
+
def __init__(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
response: httpx2.Response,
|
|
244
|
+
model: type,
|
|
245
|
+
original: BaseException,
|
|
246
|
+
) -> None:
|
|
247
|
+
self.response = response
|
|
248
|
+
self.model = model
|
|
249
|
+
self.original = original
|
|
250
|
+
super().__init__(f"failed to decode response into {model.__name__}: {original}")
|
|
251
|
+
|
|
252
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
253
|
+
return (
|
|
254
|
+
_reconstruct_decode_error,
|
|
255
|
+
(type(self), self.response, self.model, self.original),
|
|
256
|
+
)
|
|
@@ -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
|