httpware 0.3.0__tar.gz → 0.5.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.3.0 → httpware-0.5.0}/PKG-INFO +49 -10
- httpware-0.5.0/README.md +95 -0
- {httpware-0.3.0 → httpware-0.5.0}/pyproject.toml +2 -6
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/__init__.py +10 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/client.py +111 -16
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/errors.py +76 -0
- httpware-0.5.0/src/httpware/middleware/resilience/__init__.py +8 -0
- httpware-0.5.0/src/httpware/middleware/resilience/_backoff.py +26 -0
- httpware-0.5.0/src/httpware/middleware/resilience/budget.py +64 -0
- httpware-0.5.0/src/httpware/middleware/resilience/bulkhead.py +75 -0
- httpware-0.5.0/src/httpware/middleware/resilience/retry.py +174 -0
- httpware-0.3.0/README.md +0 -53
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.5.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.3.0 → httpware-0.5.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.5.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,17 +15,14 @@ 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
|
|
18
|
+
Requires-Dist: httpware[pydantic,msgspec] ; extra == 'all'
|
|
19
19
|
Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
20
|
-
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
21
|
-
Requires-Dist: opentelemetry-sdk>=1.20 ; extra == 'otel'
|
|
22
20
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
23
21
|
Requires-Python: >=3.11, <4
|
|
24
22
|
Project-URL: repository, https://github.com/modern-python/httpware
|
|
25
23
|
Project-URL: docs, https://httpware.readthedocs.io
|
|
26
24
|
Provides-Extra: all
|
|
27
25
|
Provides-Extra: msgspec
|
|
28
|
-
Provides-Extra: otel
|
|
29
26
|
Provides-Extra: pydantic
|
|
30
27
|
Description-Content-Type: text/markdown
|
|
31
28
|
|
|
@@ -38,9 +35,9 @@ Description-Content-Type: text/markdown
|
|
|
38
35
|
|
|
39
36
|
**Async HTTP client framework for Python.**
|
|
40
37
|
|
|
41
|
-
`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.
|
|
38
|
+
`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`.
|
|
42
39
|
|
|
43
|
-
> **Status:** Pre-1.0
|
|
40
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped.
|
|
44
41
|
|
|
45
42
|
## Install
|
|
46
43
|
|
|
@@ -48,10 +45,10 @@ Description-Content-Type: text/markdown
|
|
|
48
45
|
pip install httpware # core only — no decoder
|
|
49
46
|
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
50
47
|
pip install httpware[msgspec] # + MsgspecDecoder
|
|
51
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec
|
|
48
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
52
49
|
```
|
|
53
50
|
|
|
54
|
-
`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.
|
|
51
|
+
`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.
|
|
55
52
|
|
|
56
53
|
## Quickstart
|
|
57
54
|
|
|
@@ -73,7 +70,49 @@ async def main() -> None:
|
|
|
73
70
|
print(user.name)
|
|
74
71
|
```
|
|
75
72
|
|
|
76
|
-
|
|
73
|
+
### With resilience middleware
|
|
74
|
+
|
|
75
|
+
Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from httpware import AsyncClient, Bulkhead, Retry
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def main() -> None:
|
|
82
|
+
async with AsyncClient(
|
|
83
|
+
base_url="https://api.example.com",
|
|
84
|
+
middleware=[
|
|
85
|
+
Bulkhead(max_concurrent=10), # cap total in-flight
|
|
86
|
+
Retry(), # default: 3 attempts, full-jitter backoff
|
|
87
|
+
],
|
|
88
|
+
) as client:
|
|
89
|
+
user = await client.get("/users/1", response_model=User)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Streaming responses
|
|
93
|
+
|
|
94
|
+
For large responses or server-sent events, stream the body chunk-by-chunk. `stream()` is an async context manager:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from httpware import AsyncClient
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def main() -> None:
|
|
101
|
+
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
102
|
+
async with client.stream("GET", "/big-file") as response:
|
|
103
|
+
async for chunk in response.aiter_bytes():
|
|
104
|
+
process(chunk)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`stream()` auto-raises `StatusError` subclasses on 4xx/5xx with the response body pre-read, so `exc.response.content` is accessible from the caught exception.
|
|
108
|
+
|
|
109
|
+
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.)
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
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`.
|
|
114
|
+
|
|
115
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
77
116
|
|
|
78
117
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
79
118
|
|
httpware-0.5.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# httpware
|
|
2
|
+
|
|
3
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/httpware/)
|
|
5
|
+
[](https://pypi.org/project/httpware/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**Async HTTP client framework for Python.**
|
|
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 — `Retry` middleware with a Finagle-style `RetryBudget`, plus a `Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
11
|
+
|
|
12
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0. Streaming and observability are not yet shipped.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install httpware # core only — no decoder
|
|
18
|
+
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
19
|
+
pip install httpware[msgspec] # + MsgspecDecoder
|
|
20
|
+
pip install httpware[all] # everything declared above (pydantic, msgspec)
|
|
21
|
+
```
|
|
22
|
+
|
|
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.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
> Requires: `pip install httpware[pydantic]`
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from httpware import AsyncClient
|
|
31
|
+
from pydantic import BaseModel
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class User(BaseModel):
|
|
35
|
+
id: int
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def main() -> None:
|
|
40
|
+
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
41
|
+
user = await client.get("/users/1", response_model=User)
|
|
42
|
+
print(user.name)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### With resilience middleware
|
|
46
|
+
|
|
47
|
+
Compose resilience middleware at construction; `Bulkhead` goes outside `Retry` so one slot covers all retry attempts.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from httpware import AsyncClient, Bulkhead, Retry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
async with AsyncClient(
|
|
55
|
+
base_url="https://api.example.com",
|
|
56
|
+
middleware=[
|
|
57
|
+
Bulkhead(max_concurrent=10), # cap total in-flight
|
|
58
|
+
Retry(), # default: 3 attempts, full-jitter backoff
|
|
59
|
+
],
|
|
60
|
+
) as client:
|
|
61
|
+
user = await client.get("/users/1", response_model=User)
|
|
62
|
+
```
|
|
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
|
+
|
|
83
|
+
## Errors
|
|
84
|
+
|
|
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`.
|
|
86
|
+
|
|
87
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
88
|
+
|
|
89
|
+
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
90
|
+
|
|
91
|
+
## 📝 [License](./LICENSE)
|
|
92
|
+
|
|
93
|
+
## Part of `modern-python`
|
|
94
|
+
|
|
95
|
+
Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.5.0"
|
|
30
30
|
dependencies = [
|
|
31
31
|
"httpx2>=2.0.0,<3.0",
|
|
32
32
|
]
|
|
@@ -34,11 +34,7 @@ dependencies = [
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
35
|
pydantic = ["pydantic>=2.0,<3.0"]
|
|
36
36
|
msgspec = ["msgspec>=0.18"]
|
|
37
|
-
|
|
38
|
-
"opentelemetry-api>=1.20",
|
|
39
|
-
"opentelemetry-sdk>=1.20",
|
|
40
|
-
]
|
|
41
|
-
all = ["httpware[pydantic,msgspec,otel]"]
|
|
37
|
+
all = ["httpware[pydantic,msgspec]"]
|
|
42
38
|
|
|
43
39
|
[project.urls]
|
|
44
40
|
repository = "https://github.com/modern-python/httpware"
|
|
@@ -5,13 +5,16 @@ from httpware.decoders import ResponseDecoder
|
|
|
5
5
|
from httpware.errors import (
|
|
6
6
|
STATUS_TO_EXCEPTION,
|
|
7
7
|
BadRequestError,
|
|
8
|
+
BulkheadFullError,
|
|
8
9
|
ClientError,
|
|
9
10
|
ClientStatusError,
|
|
10
11
|
ConflictError,
|
|
11
12
|
ForbiddenError,
|
|
12
13
|
InternalServerError,
|
|
14
|
+
NetworkError,
|
|
13
15
|
NotFoundError,
|
|
14
16
|
RateLimitedError,
|
|
17
|
+
RetryBudgetExhaustedError,
|
|
15
18
|
ServerStatusError,
|
|
16
19
|
ServiceUnavailableError,
|
|
17
20
|
StatusError,
|
|
@@ -21,22 +24,29 @@ from httpware.errors import (
|
|
|
21
24
|
UnprocessableEntityError,
|
|
22
25
|
)
|
|
23
26
|
from httpware.middleware import Middleware, Next, after_response, before_request, on_error
|
|
27
|
+
from httpware.middleware.resilience import Bulkhead, Retry, RetryBudget
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
__all__ = [
|
|
27
31
|
"STATUS_TO_EXCEPTION",
|
|
28
32
|
"AsyncClient",
|
|
29
33
|
"BadRequestError",
|
|
34
|
+
"Bulkhead",
|
|
35
|
+
"BulkheadFullError",
|
|
30
36
|
"ClientError",
|
|
31
37
|
"ClientStatusError",
|
|
32
38
|
"ConflictError",
|
|
33
39
|
"ForbiddenError",
|
|
34
40
|
"InternalServerError",
|
|
35
41
|
"Middleware",
|
|
42
|
+
"NetworkError",
|
|
36
43
|
"Next",
|
|
37
44
|
"NotFoundError",
|
|
38
45
|
"RateLimitedError",
|
|
39
46
|
"ResponseDecoder",
|
|
47
|
+
"Retry",
|
|
48
|
+
"RetryBudget",
|
|
49
|
+
"RetryBudgetExhaustedError",
|
|
40
50
|
"ServerStatusError",
|
|
41
51
|
"ServiceUnavailableError",
|
|
42
52
|
"StatusError",
|
|
@@ -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
|
|
@@ -11,6 +12,7 @@ from httpware.decoders import ResponseDecoder
|
|
|
11
12
|
from httpware.errors import (
|
|
12
13
|
STATUS_TO_EXCEPTION,
|
|
13
14
|
ClientStatusError,
|
|
15
|
+
NetworkError,
|
|
14
16
|
ServerStatusError,
|
|
15
17
|
TimeoutError, # noqa: A004
|
|
16
18
|
TransportError,
|
|
@@ -43,6 +45,48 @@ def _default_pydantic_decoder() -> ResponseDecoder:
|
|
|
43
45
|
return PydanticDecoder()
|
|
44
46
|
|
|
45
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
|
+
|
|
46
90
|
class AsyncClient:
|
|
47
91
|
"""Async HTTP client: thin wrapper around httpx2 with typed decoding and middleware."""
|
|
48
92
|
|
|
@@ -105,24 +149,13 @@ class AsyncClient:
|
|
|
105
149
|
|
|
106
150
|
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
107
151
|
try:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
raise TimeoutError(str(exc)) from exc
|
|
111
|
-
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
|
|
112
|
-
raise TransportError(str(exc)) from exc
|
|
113
|
-
except httpx2.HTTPError as exc:
|
|
114
|
-
raise TransportError(str(exc)) from exc
|
|
152
|
+
async with _httpx2_exception_mapper():
|
|
153
|
+
response = await self._httpx2_client.send(request)
|
|
115
154
|
except RuntimeError as exc:
|
|
116
155
|
if "closed" in str(exc):
|
|
117
156
|
raise TransportError(str(exc)) from exc
|
|
118
157
|
raise
|
|
119
|
-
|
|
120
|
-
if HTTPStatus.BAD_REQUEST <= status < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
121
|
-
exc_class = STATUS_TO_EXCEPTION.get(
|
|
122
|
-
status,
|
|
123
|
-
ClientStatusError if status < HTTPStatus.INTERNAL_SERVER_ERROR else ServerStatusError,
|
|
124
|
-
)
|
|
125
|
-
raise exc_class(response)
|
|
158
|
+
_raise_on_status_error(response)
|
|
126
159
|
return response
|
|
127
160
|
|
|
128
161
|
@typing.overload
|
|
@@ -147,7 +180,7 @@ class AsyncClient:
|
|
|
147
180
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
148
181
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
149
182
|
|
|
150
|
-
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
|
|
151
184
|
self,
|
|
152
185
|
method: str,
|
|
153
186
|
url: str,
|
|
@@ -183,6 +216,8 @@ class AsyncClient:
|
|
|
183
216
|
if files is not None:
|
|
184
217
|
kwargs["files"] = files
|
|
185
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
|
|
186
221
|
return await self.send(request, response_model=response_model)
|
|
187
222
|
|
|
188
223
|
@typing.overload
|
|
@@ -660,6 +695,66 @@ class AsyncClient:
|
|
|
660
695
|
response_model=response_model,
|
|
661
696
|
)
|
|
662
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
|
+
|
|
663
758
|
async def __aenter__(self) -> typing.Self:
|
|
664
759
|
"""Enter the async context manager; return self."""
|
|
665
760
|
return self
|
|
@@ -40,6 +40,14 @@ class TransportError(ClientError):
|
|
|
40
40
|
"""Connection / network / protocol failure raised before a response was received."""
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class NetworkError(TransportError):
|
|
44
|
+
"""Transient network-layer failure (connect/read/write/close). Safe to retry.
|
|
45
|
+
|
|
46
|
+
Pool-acquisition timeouts are NOT under this class; they raise ``TimeoutError``
|
|
47
|
+
via ``httpx2.PoolTimeout`` (a ``TimeoutException`` subclass).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
43
51
|
class TimeoutError(ClientError, builtins.TimeoutError): # noqa: A001
|
|
44
52
|
"""Client-side timeout (connect / read / write / pool).
|
|
45
53
|
|
|
@@ -136,3 +144,71 @@ STATUS_TO_EXCEPTION: Mapping[int, type[StatusError]] = {
|
|
|
136
144
|
500: InternalServerError,
|
|
137
145
|
503: ServiceUnavailableError,
|
|
138
146
|
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _reconstruct_budget_exhausted(
|
|
150
|
+
cls: "type[RetryBudgetExhaustedError]",
|
|
151
|
+
last_response: httpx2.Response | None,
|
|
152
|
+
last_exception: BaseException | None,
|
|
153
|
+
attempts: int,
|
|
154
|
+
) -> "RetryBudgetExhaustedError":
|
|
155
|
+
return cls(last_response=last_response, last_exception=last_exception, attempts=attempts)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RetryBudgetExhaustedError(ClientError):
|
|
159
|
+
"""Raised when a retry was needed but the RetryBudget refused to permit it.
|
|
160
|
+
|
|
161
|
+
Carries the last response and/or exception observed before the budget refused,
|
|
162
|
+
plus the number of attempts already completed.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
last_response: httpx2.Response | None
|
|
166
|
+
last_exception: BaseException | None
|
|
167
|
+
attempts: int
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
last_response: httpx2.Response | None,
|
|
173
|
+
last_exception: BaseException | None,
|
|
174
|
+
attempts: int,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.last_response = last_response
|
|
177
|
+
self.last_exception = last_exception
|
|
178
|
+
self.attempts = attempts
|
|
179
|
+
super().__init__(f"retry budget exhausted after {attempts} attempt(s)")
|
|
180
|
+
|
|
181
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
182
|
+
return (
|
|
183
|
+
_reconstruct_budget_exhausted,
|
|
184
|
+
(type(self), self.last_response, self.last_exception, self.attempts),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _reconstruct_bulkhead_full(
|
|
189
|
+
cls: "type[BulkheadFullError]",
|
|
190
|
+
max_concurrent: int,
|
|
191
|
+
acquire_timeout: float | None,
|
|
192
|
+
) -> "BulkheadFullError":
|
|
193
|
+
return cls(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class BulkheadFullError(ClientError):
|
|
197
|
+
"""Raised when ``acquire_timeout`` elapses before a Bulkhead slot becomes available.
|
|
198
|
+
|
|
199
|
+
Carries the configured caps for caller logging/alerting.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
max_concurrent: int
|
|
203
|
+
acquire_timeout: float | None
|
|
204
|
+
|
|
205
|
+
def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> None:
|
|
206
|
+
self.max_concurrent = max_concurrent
|
|
207
|
+
self.acquire_timeout = acquire_timeout
|
|
208
|
+
super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})")
|
|
209
|
+
|
|
210
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
211
|
+
return (
|
|
212
|
+
_reconstruct_bulkhead_full,
|
|
213
|
+
(type(self), self.max_concurrent, self.acquire_timeout),
|
|
214
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Resilience primitives: Bulkhead, Retry middleware, and RetryBudget token bucket."""
|
|
2
|
+
|
|
3
|
+
from httpware.middleware.resilience.budget import RetryBudget
|
|
4
|
+
from httpware.middleware.resilience.bulkhead import Bulkhead
|
|
5
|
+
from httpware.middleware.resilience.retry import Retry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ["Bulkhead", "Retry", "RetryBudget"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Full-jitter exponential backoff helper (private)."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def full_jitter_delay(
|
|
8
|
+
attempt_index: int,
|
|
9
|
+
*,
|
|
10
|
+
base_delay: float,
|
|
11
|
+
max_delay: float,
|
|
12
|
+
_random_uniform: Callable[[float, float], float] = random.uniform,
|
|
13
|
+
) -> float:
|
|
14
|
+
"""Return a backoff delay using AWS's "full jitter" formulation.
|
|
15
|
+
|
|
16
|
+
sleep = uniform(0, min(max_delay, base_delay * 2.0 ** attempt_index))
|
|
17
|
+
|
|
18
|
+
`attempt_index` is 0 for the first retry, 1 for the second, etc.
|
|
19
|
+
|
|
20
|
+
Uses ``2.0 **`` (float exponentiation) rather than ``2 **`` so that
|
|
21
|
+
``attempt_index >= 1024`` saturates to ``math.inf`` and ``min`` clamps to
|
|
22
|
+
``max_delay`` — ``2 ** 1024`` would raise ``OverflowError`` during the
|
|
23
|
+
int→float conversion.
|
|
24
|
+
"""
|
|
25
|
+
ceiling = min(max_delay, base_delay * (2.0**attempt_index))
|
|
26
|
+
return _random_uniform(0.0, ceiling)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Finagle-style token-bucket retry budget.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the contract.
|
|
4
|
+
No locking: asyncio runs coroutines cooperatively on a single thread, so deque
|
|
5
|
+
mutations between await points are atomic with respect to other coroutines on
|
|
6
|
+
the same event loop. Cross-thread use is out of scope.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RetryBudget:
|
|
15
|
+
"""Token-bucket budget bounding retry rate to prevent retry storms.
|
|
16
|
+
|
|
17
|
+
Each request deposits a token; each retry attempts to withdraw one.
|
|
18
|
+
Available retries are bounded by `percent_can_retry` of recent deposits,
|
|
19
|
+
plus a `min_retries_per_sec * ttl` floor.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
ttl: float = 10.0,
|
|
26
|
+
min_retries_per_sec: float = 10.0,
|
|
27
|
+
percent_can_retry: float = 0.2,
|
|
28
|
+
_now: Callable[[], float] = time.monotonic,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._ttl = ttl
|
|
31
|
+
self._min_retries_per_sec = min_retries_per_sec
|
|
32
|
+
self._percent_can_retry = percent_can_retry
|
|
33
|
+
self._now = _now
|
|
34
|
+
self._deposits: deque[float] = deque()
|
|
35
|
+
self._withdrawn: deque[float] = deque()
|
|
36
|
+
|
|
37
|
+
def _purge(self, now: float) -> None:
|
|
38
|
+
# Strict `< cutoff` keeps entries at exactly `now - ttl`: window is [now - ttl, now].
|
|
39
|
+
cutoff = now - self._ttl
|
|
40
|
+
while self._deposits and self._deposits[0] < cutoff:
|
|
41
|
+
self._deposits.popleft()
|
|
42
|
+
while self._withdrawn and self._withdrawn[0] < cutoff:
|
|
43
|
+
self._withdrawn.popleft()
|
|
44
|
+
|
|
45
|
+
def deposit(self) -> None:
|
|
46
|
+
"""Record a request (success or failure attempt). Adds one token."""
|
|
47
|
+
now = self._now()
|
|
48
|
+
self._purge(now)
|
|
49
|
+
self._deposits.append(now)
|
|
50
|
+
|
|
51
|
+
def try_withdraw(self) -> bool:
|
|
52
|
+
"""Atomically attempt to spend one retry token.
|
|
53
|
+
|
|
54
|
+
Returns True if a retry is permitted, False if the budget is exhausted.
|
|
55
|
+
Never blocks.
|
|
56
|
+
"""
|
|
57
|
+
now = self._now()
|
|
58
|
+
self._purge(now)
|
|
59
|
+
floor = int(self._min_retries_per_sec * self._ttl)
|
|
60
|
+
ceiling = int(len(self._deposits) * self._percent_can_retry) + floor
|
|
61
|
+
if len(self._withdrawn) >= ceiling:
|
|
62
|
+
return False
|
|
63
|
+
self._withdrawn.append(now)
|
|
64
|
+
return True
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Bulkhead middleware — concurrency limiter via asyncio.Semaphore.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-05-bulkhead-design.md for the contract.
|
|
4
|
+
|
|
5
|
+
The middleware owns an asyncio.Semaphore(max_concurrent). On each request,
|
|
6
|
+
it acquires a slot (bounded by acquire_timeout via asyncio.timeout) and
|
|
7
|
+
releases the slot in a try/finally so success, exceptions, and cancellation
|
|
8
|
+
all release deterministically.
|
|
9
|
+
|
|
10
|
+
Bulkhead is the sharable unit — pass the same instance to multiple
|
|
11
|
+
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
import httpx2
|
|
17
|
+
|
|
18
|
+
from httpware.errors import BulkheadFullError
|
|
19
|
+
from httpware.middleware import Next
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
|
|
23
|
+
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Bulkhead:
|
|
27
|
+
"""Concurrency limiter middleware backed by ``asyncio.Semaphore``.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
max_concurrent
|
|
32
|
+
Required. Maximum number of in-flight requests this Bulkhead permits.
|
|
33
|
+
Must be ``>= 1``. There is no default because no value is universally
|
|
34
|
+
correct — the right cap depends on downstream capacity and SLA.
|
|
35
|
+
acquire_timeout
|
|
36
|
+
Seconds to wait for a slot before raising ``BulkheadFullError``.
|
|
37
|
+
Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
|
|
38
|
+
``>= 0`` (or ``None``).
|
|
39
|
+
|
|
40
|
+
See the module docstring for the algorithm and middleware-ordering guidance.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
max_concurrent: int,
|
|
48
|
+
acquire_timeout: float | None = 1.0,
|
|
49
|
+
) -> None:
|
|
50
|
+
if max_concurrent < 1:
|
|
51
|
+
raise ValueError(_MAX_CONCURRENT_INVALID)
|
|
52
|
+
if acquire_timeout is not None and acquire_timeout < 0:
|
|
53
|
+
raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
|
|
54
|
+
self._max_concurrent = max_concurrent
|
|
55
|
+
self._acquire_timeout = acquire_timeout
|
|
56
|
+
self._sem = asyncio.Semaphore(max_concurrent)
|
|
57
|
+
|
|
58
|
+
async def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
|
|
59
|
+
"""Acquire a slot (bounded by acquire_timeout), invoke next, release."""
|
|
60
|
+
try:
|
|
61
|
+
if self._acquire_timeout is None:
|
|
62
|
+
await self._sem.acquire()
|
|
63
|
+
else:
|
|
64
|
+
async with asyncio.timeout(self._acquire_timeout):
|
|
65
|
+
await self._sem.acquire()
|
|
66
|
+
except TimeoutError as exc:
|
|
67
|
+
raise BulkheadFullError(
|
|
68
|
+
max_concurrent=self._max_concurrent,
|
|
69
|
+
acquire_timeout=self._acquire_timeout,
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
return await next(request)
|
|
74
|
+
finally:
|
|
75
|
+
self._sem.release()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Retry middleware — automatic retry of transient failures with budget control.
|
|
2
|
+
|
|
3
|
+
See planning/specs/2026-06-05-retry-and-retry-budget-design.md for the full contract.
|
|
4
|
+
|
|
5
|
+
Status-code retry: the AsyncClient terminal raises StatusError subclasses on 4xx/5xx,
|
|
6
|
+
so Retry catches StatusError and inspects exc.response.status_code. The original
|
|
7
|
+
StatusError subclass is re-raised unwrapped on exhaustion, with a PEP 678 note added.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import builtins
|
|
12
|
+
import datetime
|
|
13
|
+
import email.utils
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from http import HTTPStatus
|
|
16
|
+
|
|
17
|
+
import httpx2
|
|
18
|
+
|
|
19
|
+
from httpware.client import STREAMING_BODY_MARKER
|
|
20
|
+
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
21
|
+
from httpware.middleware import Next
|
|
22
|
+
from httpware.middleware.resilience._backoff import full_jitter_delay
|
|
23
|
+
from httpware.middleware.resilience.budget import RetryBudget
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_RETRY_STATUS_CODES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
int(HTTPStatus.REQUEST_TIMEOUT),
|
|
29
|
+
int(HTTPStatus.TOO_MANY_REQUESTS),
|
|
30
|
+
int(HTTPStatus.BAD_GATEWAY),
|
|
31
|
+
int(HTTPStatus.SERVICE_UNAVAILABLE),
|
|
32
|
+
int(HTTPStatus.GATEWAY_TIMEOUT),
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"GET",
|
|
39
|
+
"HEAD",
|
|
40
|
+
"OPTIONS",
|
|
41
|
+
"PUT",
|
|
42
|
+
"DELETE",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
47
|
+
_STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
51
|
+
"""Parse a Retry-After header value. Returns None on malformed input."""
|
|
52
|
+
try:
|
|
53
|
+
return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
|
|
61
|
+
return None
|
|
62
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
63
|
+
delta = (parsed - now).total_seconds()
|
|
64
|
+
return max(0.0, delta)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Retry:
|
|
68
|
+
"""Retry middleware. See module docstring for default policy."""
|
|
69
|
+
|
|
70
|
+
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
max_attempts: int = 3,
|
|
74
|
+
base_delay: float = 0.1,
|
|
75
|
+
max_delay: float = 5.0,
|
|
76
|
+
attempt_timeout: float | None = None,
|
|
77
|
+
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
78
|
+
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
79
|
+
respect_retry_after: bool = True,
|
|
80
|
+
budget: RetryBudget | None = None,
|
|
81
|
+
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
82
|
+
) -> None:
|
|
83
|
+
if max_attempts < 1:
|
|
84
|
+
raise ValueError(_MAX_ATTEMPTS_INVALID)
|
|
85
|
+
self.max_attempts = max_attempts
|
|
86
|
+
self.base_delay = base_delay
|
|
87
|
+
self.max_delay = max_delay
|
|
88
|
+
self.attempt_timeout = attempt_timeout
|
|
89
|
+
self.retry_status_codes = retry_status_codes
|
|
90
|
+
self.retry_methods = retry_methods
|
|
91
|
+
self.respect_retry_after = respect_retry_after
|
|
92
|
+
self.budget = budget if budget is not None else RetryBudget()
|
|
93
|
+
self._sleep = _sleep
|
|
94
|
+
|
|
95
|
+
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
|
|
96
|
+
"""Process a request through the retry loop. See module docstring."""
|
|
97
|
+
method_eligible = request.method.upper() in self.retry_methods
|
|
98
|
+
last_exc: BaseException | None = None
|
|
99
|
+
last_response: httpx2.Response | None = None
|
|
100
|
+
|
|
101
|
+
for attempt in range(self.max_attempts):
|
|
102
|
+
is_last = attempt + 1 >= self.max_attempts
|
|
103
|
+
self.budget.deposit()
|
|
104
|
+
try:
|
|
105
|
+
if self.attempt_timeout is not None:
|
|
106
|
+
async with asyncio.timeout(self.attempt_timeout):
|
|
107
|
+
return await next(request)
|
|
108
|
+
else:
|
|
109
|
+
return await next(request)
|
|
110
|
+
except StatusError as exc:
|
|
111
|
+
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
112
|
+
if not method_eligible or not retryable_status:
|
|
113
|
+
if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
|
|
114
|
+
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
115
|
+
raise
|
|
116
|
+
last_exc = exc
|
|
117
|
+
last_response = exc.response
|
|
118
|
+
except (NetworkError, TimeoutError) as exc:
|
|
119
|
+
if not method_eligible:
|
|
120
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
121
|
+
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
122
|
+
raise
|
|
123
|
+
last_exc = exc
|
|
124
|
+
last_response = None
|
|
125
|
+
except builtins.TimeoutError as exc:
|
|
126
|
+
wrapped = TimeoutError("attempt timed out")
|
|
127
|
+
wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
|
|
128
|
+
if not method_eligible:
|
|
129
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
130
|
+
wrapped.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
131
|
+
raise wrapped from exc
|
|
132
|
+
last_exc = wrapped
|
|
133
|
+
last_response = None
|
|
134
|
+
|
|
135
|
+
# ---- retryable failure path
|
|
136
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
137
|
+
if last_exc is None: # pragma: no cover — invariant from except branch
|
|
138
|
+
msg = "Retry: streaming-body refusal reached with no last_exc"
|
|
139
|
+
raise AssertionError(msg)
|
|
140
|
+
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
141
|
+
raise last_exc
|
|
142
|
+
|
|
143
|
+
if is_last:
|
|
144
|
+
if last_exc is None: # pragma: no cover — structural invariant from except branch
|
|
145
|
+
msg = "Retry: last_exc unset on final attempt — unreachable"
|
|
146
|
+
raise AssertionError(msg)
|
|
147
|
+
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
148
|
+
raise last_exc
|
|
149
|
+
|
|
150
|
+
if not self.budget.try_withdraw():
|
|
151
|
+
raise RetryBudgetExhaustedError(
|
|
152
|
+
last_response=last_response,
|
|
153
|
+
last_exception=last_exc,
|
|
154
|
+
attempts=attempt + 1,
|
|
155
|
+
) from last_exc
|
|
156
|
+
|
|
157
|
+
retry_after: float | None = None
|
|
158
|
+
if self.respect_retry_after and last_response is not None:
|
|
159
|
+
header = last_response.headers.get("Retry-After")
|
|
160
|
+
if header is not None:
|
|
161
|
+
retry_after = _parse_retry_after(header)
|
|
162
|
+
|
|
163
|
+
if retry_after is not None:
|
|
164
|
+
delay = min(retry_after, self.max_delay)
|
|
165
|
+
else:
|
|
166
|
+
delay = full_jitter_delay(
|
|
167
|
+
attempt,
|
|
168
|
+
base_delay=self.base_delay,
|
|
169
|
+
max_delay=self.max_delay,
|
|
170
|
+
)
|
|
171
|
+
await self._sleep(delay)
|
|
172
|
+
|
|
173
|
+
msg = "unreachable" # pragma: no cover
|
|
174
|
+
raise AssertionError(msg) # pragma: no cover
|
httpware-0.3.0/README.md
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# httpware
|
|
2
|
-
|
|
3
|
-
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
4
|
-
[](https://pypi.org/project/httpware/)
|
|
5
|
-
[](https://pypi.org/project/httpware/)
|
|
6
|
-
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
|
|
8
|
-
**Async HTTP client framework for Python.**
|
|
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.
|
|
11
|
-
|
|
12
|
-
> **Status:** Pre-1.0 (0.3.0). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped.
|
|
13
|
-
|
|
14
|
-
## Install
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
pip install httpware # core only — no decoder
|
|
18
|
-
pip install httpware[pydantic] # + PydanticDecoder (the default-decoder path)
|
|
19
|
-
pip install httpware[msgspec] # + MsgspecDecoder
|
|
20
|
-
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
21
|
-
```
|
|
22
|
-
|
|
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. The `otel` extra is declared but the OpenTelemetry middleware (Epic 5) has not shipped yet.
|
|
24
|
-
|
|
25
|
-
## Quickstart
|
|
26
|
-
|
|
27
|
-
> Requires: `pip install httpware[pydantic]`
|
|
28
|
-
|
|
29
|
-
```python
|
|
30
|
-
from httpware import AsyncClient
|
|
31
|
-
from pydantic import BaseModel
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class User(BaseModel):
|
|
35
|
-
id: int
|
|
36
|
-
name: str
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
async def main() -> None:
|
|
40
|
-
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
41
|
-
user = await client.get("/users/1", response_model=User)
|
|
42
|
-
print(user.name)
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## 📚 [Documentation](https://httpware.readthedocs.io)
|
|
46
|
-
|
|
47
|
-
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
48
|
-
|
|
49
|
-
## 📝 [License](./LICENSE)
|
|
50
|
-
|
|
51
|
-
## Part of `modern-python`
|
|
52
|
-
|
|
53
|
-
Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|