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.
Files changed (22) hide show
  1. {httpware-0.8.0 → httpware-0.8.2}/PKG-INFO +2 -2
  2. {httpware-0.8.0 → httpware-0.8.2}/README.md +1 -1
  3. {httpware-0.8.0 → httpware-0.8.2}/pyproject.toml +1 -1
  4. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/__init__.py +2 -0
  5. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/client.py +53 -3
  6. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/__init__.py +6 -1
  7. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/errors.py +42 -0
  8. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/bulkhead.py +37 -1
  9. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/__init__.py +0 -0
  10. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/exception_mapping.py +0 -0
  11. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/import_checker.py +0 -0
  12. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/observability.py +0 -0
  13. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/_internal/status.py +0 -0
  14. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/msgspec.py +0 -0
  15. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/decoders/pydantic.py +0 -0
  16. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/__init__.py +0 -0
  17. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/chain.py +0 -0
  18. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/__init__.py +0 -0
  19. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/_backoff.py +0 -0
  20. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/budget.py +0 -0
  21. {httpware-0.8.0 → httpware-0.8.2}/src/httpware/middleware/resilience/retry.py +0 -0
  22. {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.0
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
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.8.0"
29
+ version = "0.8.2"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -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
- return self._decoder.decode(response.content, response_model)
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
- return self._decoder.decode(response.content, response_model)
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 and middleware-ordering guidance.
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