httpware 0.8.1__tar.gz → 0.8.3__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.3}/PKG-INFO +6 -4
  2. {httpware-0.8.1 → httpware-0.8.3}/README.md +5 -3
  3. {httpware-0.8.1 → httpware-0.8.3}/pyproject.toml +1 -1
  4. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/client.py +46 -2
  5. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/__init__.py +1 -1
  6. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/budget.py +2 -1
  7. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/bulkhead.py +37 -1
  8. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/retry.py +29 -12
  9. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/__init__.py +0 -0
  10. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/__init__.py +0 -0
  11. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/exception_mapping.py +0 -0
  12. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/import_checker.py +0 -0
  13. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/observability.py +0 -0
  14. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/status.py +0 -0
  15. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/msgspec.py +0 -0
  16. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/pydantic.py +0 -0
  17. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/errors.py +0 -0
  18. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/__init__.py +0 -0
  19. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/chain.py +0 -0
  20. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/__init__.py +0 -0
  21. {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/_backoff.py +0 -0
  22. {httpware-0.8.1 → httpware-0.8.3}/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.3
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
@@ -35,9 +35,9 @@ Description-Content-Type: text/markdown
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
36
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
37
 
38
- **Async HTTP client framework for Python.**
38
+ **A Python HTTP client framework with sync and async clients for building resilient service clients.**
39
39
 
40
- `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
40
+ `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
41
41
 
42
42
  > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
43
43
 
@@ -101,6 +101,8 @@ async def main() -> None:
101
101
 
102
102
  Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
103
103
 
104
+ The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
105
+
104
106
  ```python
105
107
  from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
106
108
 
@@ -143,7 +145,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
143
145
 
144
146
  ## Observability
145
147
 
146
- `AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
148
+ `AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
147
149
 
148
150
  Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
149
151
 
@@ -5,9 +5,9 @@
5
5
  [![Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- **Async HTTP client framework for Python.**
8
+ **A Python HTTP client framework with sync and async clients for building resilient service clients.**
9
9
 
10
- `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
10
+ `httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
11
11
 
12
12
  > **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
13
13
 
@@ -71,6 +71,8 @@ async def main() -> None:
71
71
 
72
72
  Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
73
73
 
74
+ The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
75
+
74
76
  ```python
75
77
  from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
76
78
 
@@ -113,7 +115,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
113
115
 
114
116
  ## Observability
115
117
 
116
- `AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
118
+ `AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
117
119
 
118
120
  Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
119
121
 
@@ -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.3"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -132,7 +132,7 @@ class AsyncClient:
132
132
  async with _httpx2_exception_mapper():
133
133
  response = await self._httpx2_client.send(request)
134
134
  except RuntimeError as exc:
135
- if "closed" in str(exc):
135
+ if self._httpx2_client.is_closed:
136
136
  raise TransportError(str(exc)) from exc
137
137
  raise
138
138
  _raise_on_status_error(response)
@@ -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)
@@ -828,7 +850,7 @@ class Client:
828
850
  with _httpx2_exception_mapper_sync():
829
851
  response = self._httpx2_client.send(request)
830
852
  except RuntimeError as exc:
831
- if "closed" in str(exc):
853
+ if self._httpx2_client.is_closed:
832
854
  raise TransportError(str(exc)) from exc
833
855
  raise
834
856
  _raise_on_status_error(response)
@@ -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)
@@ -1,4 +1,4 @@
1
- """ResponseDecoder protocol — the AsyncClient ↔ ResponseDecoder seam (Seam 3)."""
1
+ """ResponseDecoder protocol — the Client/AsyncClient ↔ ResponseDecoder seam (Seam B)."""
2
2
 
3
3
  from typing import Protocol, TypeVar, runtime_checkable
4
4
 
@@ -8,6 +8,7 @@ coroutines on one event loop, and across (sync Client, AsyncClient) pairs
8
8
  in the same process.
9
9
  """
10
10
 
11
+ import math
11
12
  import threading
12
13
  import time
13
14
  from collections import deque
@@ -64,7 +65,7 @@ class RetryBudget:
64
65
  with self._lock:
65
66
  self._purge(now)
66
67
  floor = int(self._min_retries_per_sec * self._ttl)
67
- ceiling = int(len(self._deposits) * self._percent_can_retry) + floor
68
+ ceiling = math.ceil(len(self._deposits) * self._percent_can_retry) + floor
68
69
  if len(self._withdrawn) >= ceiling:
69
70
  return False
70
71
  self._withdrawn.append(now)
@@ -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()
@@ -47,6 +47,9 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
47
47
 
48
48
  _MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
49
49
  _STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
50
+ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE = (
51
+ "httpware: Retry-After ({retry_after}s) exceeded max_delay ({max_delay}s); giving up"
52
+ )
50
53
 
51
54
  _LOGGER = logging.getLogger("httpware.retry")
52
55
 
@@ -100,23 +103,19 @@ class AsyncRetry:
100
103
  last_exc: BaseException | None = None
101
104
  last_response: httpx2.Response | None = None
102
105
 
106
+ self.budget.deposit()
103
107
  for attempt in range(self.max_attempts):
104
108
  is_last = attempt + 1 >= self.max_attempts
105
- self.budget.deposit()
106
109
  try:
107
110
  return await next(request)
108
111
  except StatusError as exc:
109
112
  retryable_status = exc.response.status_code in self.retry_status_codes
110
113
  if not method_eligible or not retryable_status:
111
- if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
112
- exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
113
114
  raise
114
115
  last_exc = exc
115
116
  last_response = exc.response
116
117
  except (NetworkError, TimeoutError) as exc:
117
118
  if not method_eligible:
118
- if request.extensions.get(STREAMING_BODY_MARKER):
119
- exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
120
119
  raise
121
120
  last_exc = exc
122
121
  last_response = None
@@ -185,8 +184,19 @@ class AsyncRetry:
185
184
  if header is not None:
186
185
  retry_after = _parse_retry_after(header)
187
186
 
187
+ if retry_after is not None and retry_after > self.max_delay:
188
+ if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
189
+ msg = "AsyncRetry: retry_after path reached with no last_exc"
190
+ raise AssertionError(msg)
191
+ last_exc.add_note(
192
+ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
193
+ retry_after=retry_after,
194
+ max_delay=self.max_delay,
195
+ ),
196
+ )
197
+ raise last_exc
188
198
  if retry_after is not None:
189
- delay = min(retry_after, self.max_delay)
199
+ delay = retry_after
190
200
  else:
191
201
  delay = full_jitter_delay(
192
202
  attempt,
@@ -231,23 +241,19 @@ class Retry:
231
241
  last_exc: BaseException | None = None
232
242
  last_response: httpx2.Response | None = None
233
243
 
244
+ self.budget.deposit()
234
245
  for attempt in range(self.max_attempts):
235
246
  is_last = attempt + 1 >= self.max_attempts
236
- self.budget.deposit()
237
247
  try:
238
248
  return next(request)
239
249
  except StatusError as exc:
240
250
  retryable_status = exc.response.status_code in self.retry_status_codes
241
251
  if not method_eligible or not retryable_status:
242
- if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
243
- exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
244
252
  raise
245
253
  last_exc = exc
246
254
  last_response = exc.response
247
255
  except (NetworkError, TimeoutError) as exc:
248
256
  if not method_eligible:
249
- if request.extensions.get(STREAMING_BODY_MARKER):
250
- exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
251
257
  raise
252
258
  last_exc = exc
253
259
  last_response = None
@@ -316,8 +322,19 @@ class Retry:
316
322
  if header is not None:
317
323
  retry_after = _parse_retry_after(header)
318
324
 
325
+ if retry_after is not None and retry_after > self.max_delay:
326
+ if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
327
+ msg = "Retry: retry_after path reached with no last_exc"
328
+ raise AssertionError(msg)
329
+ last_exc.add_note(
330
+ _RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
331
+ retry_after=retry_after,
332
+ max_delay=self.max_delay,
333
+ ),
334
+ )
335
+ raise last_exc
319
336
  if retry_after is not None:
320
- delay = min(retry_after, self.max_delay)
337
+ delay = retry_after
321
338
  else:
322
339
  delay = full_jitter_delay(
323
340
  attempt,
File without changes