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.
- {httpware-0.8.2 → httpware-0.8.4}/PKG-INFO +6 -4
- {httpware-0.8.2 → httpware-0.8.4}/README.md +5 -3
- {httpware-0.8.2 → httpware-0.8.4}/pyproject.toml +1 -1
- httpware-0.8.4/src/httpware/_internal/import_checker.py +26 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/observability.py +9 -3
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/client.py +2 -2
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/__init__.py +1 -1
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/budget.py +2 -1
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/retry.py +29 -12
- httpware-0.8.2/src/httpware/_internal/import_checker.py +0 -8
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/__init__.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/_internal/status.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/errors.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.8.2 → httpware-0.8.4}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/httpware/)
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
37
|
|
|
38
|
-
**
|
|
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
|
[](https://pypi.org/project/httpware/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
**
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|