httpware 0.8.2__tar.gz → 0.8.4__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 (23) hide show
  1. {httpware-0.8.2 → httpware-0.8.4}/PKG-INFO +6 -4
  2. {httpware-0.8.2 → httpware-0.8.4}/README.md +5 -3
  3. {httpware-0.8.2 → httpware-0.8.4}/pyproject.toml +1 -1
  4. httpware-0.8.4/src/httpware/_internal/import_checker.py +26 -0
  5. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/observability.py +9 -3
  6. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/client.py +2 -2
  7. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/__init__.py +1 -1
  8. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/budget.py +2 -1
  9. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/retry.py +29 -12
  10. httpware-0.8.2/src/httpware/_internal/import_checker.py +0 -8
  11. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/__init__.py +0 -0
  12. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/__init__.py +0 -0
  13. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/exception_mapping.py +0 -0
  14. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/status.py +0 -0
  15. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/msgspec.py +0 -0
  16. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/pydantic.py +0 -0
  17. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/errors.py +0 -0
  18. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/__init__.py +0 -0
  19. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/chain.py +0 -0
  20. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/__init__.py +0 -0
  21. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/_backoff.py +0 -0
  22. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  23. {httpware-0.8.2 → httpware-0.8.4}/src/httpware/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpware
3
- Version: 0.8.2
3
+ Version: 0.8.4
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.2"
29
+ version = "0.8.4"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -0,0 +1,26 @@
1
+ """Detect optional extras without importing them. Used by adapter modules to gate hard imports."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, distribution
4
+ from importlib.util import find_spec
5
+
6
+
7
+ def _is_distribution_installed(name: str) -> bool:
8
+ """Probe the package registry for a distribution by name. No sys.modules side effects."""
9
+ try:
10
+ distribution(name)
11
+ except PackageNotFoundError:
12
+ return False
13
+ return True
14
+
15
+
16
+ is_msgspec_installed = find_spec("msgspec") is not None
17
+ is_pydantic_installed = find_spec("pydantic") is not None
18
+ # opentelemetry/ is a PEP 420 namespace package — instrumentation packages create the
19
+ # directory even without opentelemetry-api. find_spec("opentelemetry") therefore returns
20
+ # non-None regardless of whether the api package is present, and
21
+ # find_spec("opentelemetry.trace") populates sys.modules with the namespace parent as a
22
+ # CPython side-effect, breaking the isolation guarantee.
23
+ # importlib.metadata.distribution probes the package registry instead: it returns the
24
+ # distribution when opentelemetry-api is installed and raises PackageNotFoundError when
25
+ # it is absent, with no sys.modules side effects.
26
+ is_otel_installed = _is_distribution_installed("opentelemetry-api")
@@ -29,7 +29,9 @@ def _emit_event(
29
29
  ``trace.get_current_span().add_event(event_name, attributes=attributes)``.
30
30
  When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan``
31
31
  whose ``add_event`` is a documented no-op — so the call is unconditional
32
- behind the install gate.
32
+ behind the install gate. If the install gate is wrong (the namespace exists
33
+ but the api package is missing or broken), the lazy import raises
34
+ ``ImportError``; we degrade silently to log-only emission.
33
35
 
34
36
  The lazy ``from opentelemetry import trace`` inside the if-block preserves
35
37
  the optional-extras isolation invariant: ``import httpware`` must not pull
@@ -37,6 +39,10 @@ def _emit_event(
37
39
  """
38
40
  logger.log(level, message, extra=attributes)
39
41
  if import_checker.is_otel_installed:
40
- from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
41
-
42
+ try:
43
+ from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
44
+ except ImportError:
45
+ # opentelemetry namespace exists but the api package is broken or missing —
46
+ # degrade to log-only emission. The structured log record above has already fired.
47
+ return
42
48
  trace.get_current_span().add_event(event_name, attributes=attributes)
@@ -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)
@@ -850,7 +850,7 @@ class Client:
850
850
  with _httpx2_exception_mapper_sync():
851
851
  response = self._httpx2_client.send(request)
852
852
  except RuntimeError as exc:
853
- if "closed" in str(exc):
853
+ if self._httpx2_client.is_closed:
854
854
  raise TransportError(str(exc)) from exc
855
855
  raise
856
856
  _raise_on_status_error(response)
@@ -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)
@@ -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,
@@ -1,8 +0,0 @@
1
- """Detect optional extras without importing them. Used by adapter modules to gate hard imports."""
2
-
3
- from importlib.util import find_spec
4
-
5
-
6
- is_msgspec_installed = find_spec("msgspec") is not None
7
- is_pydantic_installed = find_spec("pydantic") is not None
8
- is_otel_installed = find_spec("opentelemetry") is not None
File without changes