httpware 0.4.0__tar.gz → 0.6.0__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.4.0 → httpware-0.6.0}/PKG-INFO +47 -4
- {httpware-0.4.0 → httpware-0.6.0}/README.md +43 -2
- {httpware-0.4.0 → httpware-0.6.0}/pyproject.toml +3 -2
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/_internal/import_checker.py +1 -0
- httpware-0.6.0/src/httpware/_internal/observability.py +42 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/client.py +110 -18
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/resilience/bulkhead.py +16 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/resilience/retry.py +58 -2
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/__init__.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/errors.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.4.0 → httpware-0.6.0}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
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
|
|
@@ -15,14 +15,16 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
15
15
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
16
|
Classifier: Framework :: AsyncIO
|
|
17
17
|
Requires-Dist: httpx2>=2.0.0,<3.0
|
|
18
|
-
Requires-Dist: httpware[pydantic,msgspec] ; extra == 'all'
|
|
18
|
+
Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
|
|
19
19
|
Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
20
21
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
21
22
|
Requires-Python: >=3.11, <4
|
|
22
23
|
Project-URL: repository, https://github.com/modern-python/httpware
|
|
23
24
|
Project-URL: docs, https://httpware.readthedocs.io
|
|
24
25
|
Provides-Extra: all
|
|
25
26
|
Provides-Extra: msgspec
|
|
27
|
+
Provides-Extra: otel
|
|
26
28
|
Provides-Extra: pydantic
|
|
27
29
|
Description-Content-Type: text/markdown
|
|
28
30
|
|
|
@@ -37,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
37
39
|
|
|
38
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 — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
39
41
|
|
|
40
|
-
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
42
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
41
43
|
|
|
42
44
|
## Install
|
|
43
45
|
|
|
@@ -45,7 +47,7 @@ Description-Content-Type: text/markdown
|
|
|
45
47
|
pip install httpware # core only — no decoder
|
|
46
48
|
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
47
49
|
pip install httpware[msgspec] # + MsgspecDecoder
|
|
48
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
50
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
|
|
@@ -89,10 +91,51 @@ async def main() -> None:
|
|
|
89
91
|
user = await client.get("/users/1", response_model=User)
|
|
90
92
|
```
|
|
91
93
|
|
|
94
|
+
### Streaming responses
|
|
95
|
+
|
|
96
|
+
For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from httpware import AsyncClient
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def main() -> None:
|
|
103
|
+
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
104
|
+
async with client.stream("GET", "/big-file") as response:
|
|
105
|
+
async for chunk in response.aiter_bytes():
|
|
106
|
+
process(chunk)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
|
|
110
|
+
|
|
111
|
+
It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
|
|
112
|
+
|
|
92
113
|
## Errors
|
|
93
114
|
|
|
94
115
|
All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`.
|
|
95
116
|
|
|
117
|
+
## Observability
|
|
118
|
+
|
|
119
|
+
`Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import logging
|
|
125
|
+
|
|
126
|
+
# Enable visibility into retry / bulkhead operational events
|
|
127
|
+
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
128
|
+
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For OTel attribute enrichment on the active span — install the extra:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install httpware[otel]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
|
|
138
|
+
|
|
96
139
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
97
140
|
|
|
98
141
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
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 — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
11
11
|
|
|
12
|
-
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
12
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
pip install httpware # core only — no decoder
|
|
18
18
|
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
19
19
|
pip install httpware[msgspec] # + MsgspecDecoder
|
|
20
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
20
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
`AsyncClient()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
|
|
@@ -61,10 +61,51 @@ async def main() -> None:
|
|
|
61
61
|
user = await client.get("/users/1", response_model=User)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
### Streaming responses
|
|
65
|
+
|
|
66
|
+
For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from httpware import AsyncClient
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def main() -> None:
|
|
73
|
+
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
74
|
+
async with client.stream("GET", "/big-file") as response:
|
|
75
|
+
async for chunk in response.aiter_bytes():
|
|
76
|
+
process(chunk)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
|
|
80
|
+
|
|
81
|
+
It does NOT pass through the middleware chain: `Retry`, `Bulkhead`, and any custom middleware are bypassed. (Retry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
|
|
82
|
+
|
|
64
83
|
## Errors
|
|
65
84
|
|
|
66
85
|
All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `ServiceUnavailableError`, `RateLimitedError`, etc. — all subclasses of `httpware.StatusError`. Transport-layer transient failures raise `NetworkError`; the resilience middleware raise `RetryBudgetExhaustedError` and `BulkheadFullError`. Everything inherits `httpware.ClientError`.
|
|
67
86
|
|
|
87
|
+
## Observability
|
|
88
|
+
|
|
89
|
+
`Retry` and `Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import logging
|
|
95
|
+
|
|
96
|
+
# Enable visibility into retry / bulkhead operational events
|
|
97
|
+
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
|
|
98
|
+
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
For OTel attribute enrichment on the active span — install the extra:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install httpware[otel]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
|
|
108
|
+
|
|
68
109
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
69
110
|
|
|
70
111
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.6.0"
|
|
30
30
|
dependencies = [
|
|
31
31
|
"httpx2>=2.0.0,<3.0",
|
|
32
32
|
]
|
|
@@ -34,7 +34,8 @@ dependencies = [
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
35
|
pydantic = ["pydantic>=2.0,<3.0"]
|
|
36
36
|
msgspec = ["msgspec>=0.18"]
|
|
37
|
-
|
|
37
|
+
otel = ["opentelemetry-api>=1.20"]
|
|
38
|
+
all = ["httpware[pydantic,msgspec,otel]"]
|
|
38
39
|
|
|
39
40
|
[project.urls]
|
|
40
41
|
repository = "https://github.com/modern-python/httpware"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Observability emission helper — structured logging + opt-in OpenTelemetry span events.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-05-observability-design.md for the contract.
|
|
4
|
+
|
|
5
|
+
Logger names (``httpware.retry``, ``httpware.bulkhead``) and event names
|
|
6
|
+
(``retry.giving_up``, ``bulkhead.rejected``, etc.) are the public observability
|
|
7
|
+
surface. They are stable: renames are breaking changes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
from httpware._internal import import_checker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _emit_event(
|
|
17
|
+
logger: logging.Logger,
|
|
18
|
+
event_name: str,
|
|
19
|
+
*,
|
|
20
|
+
level: int,
|
|
21
|
+
message: str,
|
|
22
|
+
attributes: dict[str, typing.Any],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Emit one observability event to both channels.
|
|
25
|
+
|
|
26
|
+
1. Always emits a structured log record at ``level`` with ``extra=attributes``
|
|
27
|
+
(so log aggregators that index ``extra`` see structured fields).
|
|
28
|
+
2. If ``opentelemetry-api`` is installed, calls
|
|
29
|
+
``trace.get_current_span().add_event(event_name, attributes=attributes)``.
|
|
30
|
+
When no tracer is active, ``get_current_span()`` returns a ``NonRecordingSpan``
|
|
31
|
+
whose ``add_event`` is a documented no-op — so the call is unconditional
|
|
32
|
+
behind the install gate.
|
|
33
|
+
|
|
34
|
+
The lazy ``from opentelemetry import trace`` inside the if-block preserves
|
|
35
|
+
the optional-extras isolation invariant: ``import httpware`` must not pull
|
|
36
|
+
``opentelemetry`` into ``sys.modules`` when the extra is absent.
|
|
37
|
+
"""
|
|
38
|
+
logger.log(level, message, extra=attributes)
|
|
39
|
+
if import_checker.is_otel_installed:
|
|
40
|
+
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
|
|
41
|
+
|
|
42
|
+
trace.get_current_span().add_event(event_name, attributes=attributes)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""AsyncClient — the thin httpx2 wrapper."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import typing
|
|
4
|
-
from collections.abc import Sequence
|
|
5
|
+
from collections.abc import AsyncIterator, Sequence
|
|
5
6
|
from http import HTTPStatus
|
|
6
7
|
|
|
7
8
|
import httpx2
|
|
@@ -44,6 +45,48 @@ def _default_pydantic_decoder() -> ResponseDecoder:
|
|
|
44
45
|
return PydanticDecoder()
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
@contextlib.asynccontextmanager
|
|
49
|
+
async def _httpx2_exception_mapper() -> AsyncIterator[None]:
|
|
50
|
+
"""Map httpx2 exceptions to httpware exceptions. Shared by AsyncClient._terminal and stream()."""
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
except httpx2.TimeoutException as exc:
|
|
54
|
+
raise TimeoutError(str(exc)) from exc
|
|
55
|
+
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
|
|
56
|
+
raise TransportError(str(exc)) from exc
|
|
57
|
+
except httpx2.NetworkError as exc:
|
|
58
|
+
raise NetworkError(str(exc)) from exc
|
|
59
|
+
except httpx2.HTTPError as exc:
|
|
60
|
+
raise TransportError(str(exc)) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _raise_on_status_error(response: httpx2.Response) -> None:
|
|
64
|
+
"""Raise the appropriate StatusError subclass for a 4xx/5xx response. No-op for 2xx/3xx."""
|
|
65
|
+
status = response.status_code
|
|
66
|
+
if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
67
|
+
exc_class = STATUS_TO_EXCEPTION.get(
|
|
68
|
+
status,
|
|
69
|
+
ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
|
|
70
|
+
)
|
|
71
|
+
raise exc_class(response)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
STREAMING_BODY_MARKER = "httpware.streaming_body"
|
|
75
|
+
"""Key set on ``httpx2.Request.extensions`` by ``_request_with_body`` when content/data/files is an async-iterable.
|
|
76
|
+
|
|
77
|
+
``Retry.__call__`` reads this marker to refuse retrying a streamed-body request
|
|
78
|
+
(the consumed iterator cannot replay across attempts)."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_streaming_body(value: typing.Any) -> bool:
|
|
82
|
+
"""Return True if value is an async-iterable that cannot be safely replayed for retry."""
|
|
83
|
+
if value is None:
|
|
84
|
+
return False
|
|
85
|
+
if isinstance(value, (bytes, bytearray, memoryview, str, dict)):
|
|
86
|
+
return False
|
|
87
|
+
return hasattr(value, "__aiter__")
|
|
88
|
+
|
|
89
|
+
|
|
47
90
|
class AsyncClient:
|
|
48
91
|
"""Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
|
|
49
92
|
|
|
@@ -106,26 +149,13 @@ class AsyncClient:
|
|
|
106
149
|
|
|
107
150
|
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
108
151
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
raise TimeoutError(str(exc)) from exc
|
|
112
|
-
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
|
|
113
|
-
raise TransportError(str(exc)) from exc
|
|
114
|
-
except httpx2.NetworkError as exc:
|
|
115
|
-
raise NetworkError(str(exc)) from exc
|
|
116
|
-
except httpx2.HTTPError as exc:
|
|
117
|
-
raise TransportError(str(exc)) from exc
|
|
152
|
+
async with _httpx2_exception_mapper():
|
|
153
|
+
response = await self._httpx2_client.send(request)
|
|
118
154
|
except RuntimeError as exc:
|
|
119
155
|
if "closed" in str(exc):
|
|
120
156
|
raise TransportError(str(exc)) from exc
|
|
121
157
|
raise
|
|
122
|
-
|
|
123
|
-
if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
124
|
-
exc_class = STATUS_TO_EXCEPTION.get(
|
|
125
|
-
status,
|
|
126
|
-
ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
|
|
127
|
-
)
|
|
128
|
-
raise exc_class(response)
|
|
158
|
+
_raise_on_status_error(response)
|
|
129
159
|
return response
|
|
130
160
|
|
|
131
161
|
@typing.overload
|
|
@@ -150,7 +180,7 @@ class AsyncClient:
|
|
|
150
180
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
151
181
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
152
182
|
|
|
153
|
-
async def _request_with_body( # noqa: PLR0913 — mirrors httpx2 per-method signatures
|
|
183
|
+
async def _request_with_body( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
|
|
154
184
|
self,
|
|
155
185
|
method: str,
|
|
156
186
|
url: str,
|
|
@@ -186,6 +216,8 @@ class AsyncClient:
|
|
|
186
216
|
if files is not None:
|
|
187
217
|
kwargs["files"] = files
|
|
188
218
|
request = self._httpx2_client.build_request(method, url, **kwargs)
|
|
219
|
+
if _is_streaming_body(content) or _is_streaming_body(data) or _is_streaming_body(files):
|
|
220
|
+
request.extensions[STREAMING_BODY_MARKER] = True
|
|
189
221
|
return await self.send(request, response_model=response_model)
|
|
190
222
|
|
|
191
223
|
@typing.overload
|
|
@@ -663,6 +695,66 @@ class AsyncClient:
|
|
|
663
695
|
response_model=response_model,
|
|
664
696
|
)
|
|
665
697
|
|
|
698
|
+
@contextlib.asynccontextmanager
|
|
699
|
+
async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
|
|
700
|
+
self,
|
|
701
|
+
method: str,
|
|
702
|
+
url: str,
|
|
703
|
+
*,
|
|
704
|
+
params: typing.Any | None = None,
|
|
705
|
+
headers: typing.Any | None = None,
|
|
706
|
+
cookies: typing.Any | None = None,
|
|
707
|
+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
|
|
708
|
+
extensions: typing.Any | None = None,
|
|
709
|
+
json: typing.Any | None = None,
|
|
710
|
+
content: typing.Any | None = None,
|
|
711
|
+
data: typing.Any | None = None,
|
|
712
|
+
files: typing.Any | None = None,
|
|
713
|
+
) -> AsyncIterator[httpx2.Response]:
|
|
714
|
+
"""Stream an HTTP response. Bypasses the middleware chain.
|
|
715
|
+
|
|
716
|
+
Yields an httpx2.Response; consume the body via response.aiter_bytes(),
|
|
717
|
+
response.aiter_text(), response.aiter_lines(), or response.aiter_raw().
|
|
718
|
+
The body is NOT pre-read for 2xx/3xx (streaming preserved); the response
|
|
719
|
+
is closed when the context exits.
|
|
720
|
+
|
|
721
|
+
Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed
|
|
722
|
+
middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md.
|
|
723
|
+
|
|
724
|
+
Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError,
|
|
725
|
+
ServiceUnavailableError, etc.) — consistent with client.get()/post()/etc.
|
|
726
|
+
On error the response body is pre-read so exc.response.content is
|
|
727
|
+
accessible. You lose the streaming property on errors; rare in practice.
|
|
728
|
+
|
|
729
|
+
Maps httpx2 exceptions raised during the request OR body consumption to
|
|
730
|
+
httpware exceptions via _httpx2_exception_mapper.
|
|
731
|
+
"""
|
|
732
|
+
kwargs: dict[str, typing.Any] = {}
|
|
733
|
+
if params is not None:
|
|
734
|
+
kwargs["params"] = params
|
|
735
|
+
if headers is not None:
|
|
736
|
+
kwargs["headers"] = headers
|
|
737
|
+
if cookies is not None:
|
|
738
|
+
kwargs["cookies"] = cookies
|
|
739
|
+
if timeout is not httpx2.USE_CLIENT_DEFAULT:
|
|
740
|
+
kwargs["timeout"] = timeout
|
|
741
|
+
if extensions is not None:
|
|
742
|
+
kwargs["extensions"] = extensions
|
|
743
|
+
if json is not None:
|
|
744
|
+
kwargs["json"] = json
|
|
745
|
+
if content is not None:
|
|
746
|
+
kwargs["content"] = content
|
|
747
|
+
if data is not None:
|
|
748
|
+
kwargs["data"] = data
|
|
749
|
+
if files is not None:
|
|
750
|
+
kwargs["files"] = files
|
|
751
|
+
|
|
752
|
+
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
|
|
753
|
+
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
754
|
+
await response.aread() # pre-read body so exc.response.content works
|
|
755
|
+
_raise_on_status_error(response)
|
|
756
|
+
yield response
|
|
757
|
+
|
|
666
758
|
async def __aenter__(self) -> typing.Self:
|
|
667
759
|
"""Enter the async context manager; return self."""
|
|
668
760
|
return self
|
|
@@ -12,9 +12,11 @@ AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
|
+
import logging
|
|
15
16
|
|
|
16
17
|
import httpx2
|
|
17
18
|
|
|
19
|
+
from httpware._internal.observability import _emit_event
|
|
18
20
|
from httpware.errors import BulkheadFullError
|
|
19
21
|
from httpware.middleware import Next
|
|
20
22
|
|
|
@@ -22,6 +24,8 @@ from httpware.middleware import Next
|
|
|
22
24
|
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
|
|
23
25
|
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
|
|
24
26
|
|
|
27
|
+
_LOGGER = logging.getLogger("httpware.bulkhead")
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
class Bulkhead:
|
|
27
31
|
"""Concurrency limiter middleware backed by ``asyncio.Semaphore``.
|
|
@@ -64,6 +68,18 @@ class Bulkhead:
|
|
|
64
68
|
async with asyncio.timeout(self._acquire_timeout):
|
|
65
69
|
await self._sem.acquire()
|
|
66
70
|
except TimeoutError as exc:
|
|
71
|
+
_emit_event(
|
|
72
|
+
_LOGGER,
|
|
73
|
+
"bulkhead.rejected",
|
|
74
|
+
level=logging.WARNING,
|
|
75
|
+
message="bulkhead rejected request — acquire_timeout exceeded",
|
|
76
|
+
attributes={
|
|
77
|
+
"max_concurrent": self._max_concurrent,
|
|
78
|
+
"acquire_timeout": self._acquire_timeout,
|
|
79
|
+
"method": request.method,
|
|
80
|
+
"url": str(request.url),
|
|
81
|
+
},
|
|
82
|
+
)
|
|
67
83
|
raise BulkheadFullError(
|
|
68
84
|
max_concurrent=self._max_concurrent,
|
|
69
85
|
acquire_timeout=self._acquire_timeout,
|
|
@@ -11,11 +11,14 @@ import asyncio
|
|
|
11
11
|
import builtins
|
|
12
12
|
import datetime
|
|
13
13
|
import email.utils
|
|
14
|
+
import logging
|
|
14
15
|
from collections.abc import Awaitable, Callable
|
|
15
16
|
from http import HTTPStatus
|
|
16
17
|
|
|
17
18
|
import httpx2
|
|
18
19
|
|
|
20
|
+
from httpware._internal.observability import _emit_event
|
|
21
|
+
from httpware.client import STREAMING_BODY_MARKER
|
|
19
22
|
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
20
23
|
from httpware.middleware import Next
|
|
21
24
|
from httpware.middleware.resilience._backoff import full_jitter_delay
|
|
@@ -43,6 +46,9 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
|
43
46
|
)
|
|
44
47
|
|
|
45
48
|
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
49
|
+
_STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
|
|
50
|
+
|
|
51
|
+
_LOGGER = logging.getLogger("httpware.retry")
|
|
46
52
|
|
|
47
53
|
|
|
48
54
|
def _parse_retry_after(value: str) -> float | None:
|
|
@@ -90,7 +96,7 @@ class Retry:
|
|
|
90
96
|
self.budget = budget if budget is not None else RetryBudget()
|
|
91
97
|
self._sleep = _sleep
|
|
92
98
|
|
|
93
|
-
async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912 — complexity budget: 3 error clauses + idempotency gate + budget gate + Retry-After branch + backoff
|
|
99
|
+
async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — complexity budget: 3 error clauses + idempotency gate + streaming-body refusal + budget gate + Retry-After branch + backoff
|
|
94
100
|
"""Process a request through the retry loop. See module docstring."""
|
|
95
101
|
method_eligible = request.method.upper() in self.retry_methods
|
|
96
102
|
last_exc: BaseException | None = None
|
|
@@ -106,12 +112,17 @@ class Retry:
|
|
|
106
112
|
else:
|
|
107
113
|
return await next(request)
|
|
108
114
|
except StatusError as exc:
|
|
109
|
-
|
|
115
|
+
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
116
|
+
if not method_eligible or not retryable_status:
|
|
117
|
+
if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
|
|
118
|
+
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
110
119
|
raise
|
|
111
120
|
last_exc = exc
|
|
112
121
|
last_response = exc.response
|
|
113
122
|
except (NetworkError, TimeoutError) as exc:
|
|
114
123
|
if not method_eligible:
|
|
124
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
125
|
+
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
115
126
|
raise
|
|
116
127
|
last_exc = exc
|
|
117
128
|
last_response = None
|
|
@@ -119,19 +130,64 @@ class Retry:
|
|
|
119
130
|
wrapped = TimeoutError("attempt timed out")
|
|
120
131
|
wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
|
|
121
132
|
if not method_eligible:
|
|
133
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
134
|
+
wrapped.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
122
135
|
raise wrapped from exc
|
|
123
136
|
last_exc = wrapped
|
|
124
137
|
last_response = None
|
|
125
138
|
|
|
126
139
|
# ---- retryable failure path
|
|
140
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
141
|
+
if last_exc is None: # pragma: no cover — invariant from except branch
|
|
142
|
+
msg = "Retry: streaming-body refusal reached with no last_exc"
|
|
143
|
+
raise AssertionError(msg)
|
|
144
|
+
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
145
|
+
_emit_event(
|
|
146
|
+
_LOGGER,
|
|
147
|
+
"retry.streaming_refused",
|
|
148
|
+
level=logging.WARNING,
|
|
149
|
+
message="retry refused — request body is a stream that cannot replay",
|
|
150
|
+
attributes={
|
|
151
|
+
"method": request.method,
|
|
152
|
+
"url": str(request.url),
|
|
153
|
+
"last_exception_type": type(last_exc).__qualname__,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
raise last_exc
|
|
157
|
+
|
|
127
158
|
if is_last:
|
|
128
159
|
if last_exc is None: # pragma: no cover — structural invariant from except branch
|
|
129
160
|
msg = "Retry: last_exc unset on final attempt — unreachable"
|
|
130
161
|
raise AssertionError(msg)
|
|
131
162
|
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
163
|
+
_emit_event(
|
|
164
|
+
_LOGGER,
|
|
165
|
+
"retry.giving_up",
|
|
166
|
+
level=logging.WARNING,
|
|
167
|
+
message=f"retry gave up after {attempt + 1} attempts",
|
|
168
|
+
attributes={
|
|
169
|
+
"attempts": attempt + 1,
|
|
170
|
+
"method": request.method,
|
|
171
|
+
"url": str(request.url),
|
|
172
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
173
|
+
"last_exception_type": type(last_exc).__qualname__,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
132
176
|
raise last_exc
|
|
133
177
|
|
|
134
178
|
if not self.budget.try_withdraw():
|
|
179
|
+
_emit_event(
|
|
180
|
+
_LOGGER,
|
|
181
|
+
"retry.budget_refused",
|
|
182
|
+
level=logging.WARNING,
|
|
183
|
+
message=f"retry budget refused after {attempt + 1} attempts",
|
|
184
|
+
attributes={
|
|
185
|
+
"attempts": attempt + 1,
|
|
186
|
+
"method": request.method,
|
|
187
|
+
"url": str(request.url),
|
|
188
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
135
191
|
raise RetryBudgetExhaustedError(
|
|
136
192
|
last_response=last_response,
|
|
137
193
|
last_exception=last_exc,
|
|
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
|