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