httpware 0.3.0__tar.gz → 0.4.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.4.0}/PKG-INFO +30 -10
- {httpware-0.3.0 → httpware-0.4.0}/README.md +28 -5
- {httpware-0.3.0 → httpware-0.4.0}/pyproject.toml +2 -6
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/__init__.py +10 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/client.py +3 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/errors.py +76 -0
- httpware-0.4.0/src/httpware/middleware/resilience/__init__.py +8 -0
- httpware-0.4.0/src/httpware/middleware/resilience/_backoff.py +26 -0
- httpware-0.4.0/src/httpware/middleware/resilience/budget.py +64 -0
- httpware-0.4.0/src/httpware/middleware/resilience/bulkhead.py +75 -0
- httpware-0.4.0/src/httpware/middleware/resilience/retry.py +158 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.3.0 → httpware-0.4.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.3.0 → httpware-0.4.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.4.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,30 @@ 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
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
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
|
+
|
|
96
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
77
97
|
|
|
78
98
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
79
99
|
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
**Async HTTP client framework for Python.**
|
|
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.
|
|
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
|
|
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
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -17,10 +17,10 @@
|
|
|
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)
|
|
21
21
|
```
|
|
22
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.
|
|
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
24
|
|
|
25
25
|
## Quickstart
|
|
26
26
|
|
|
@@ -42,7 +42,30 @@ async def main() -> None:
|
|
|
42
42
|
print(user.name)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
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
|
+
## Errors
|
|
65
|
+
|
|
66
|
+
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
|
+
|
|
68
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
46
69
|
|
|
47
70
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
48
71
|
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.4.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",
|
|
@@ -11,6 +11,7 @@ from httpware.decoders import ResponseDecoder
|
|
|
11
11
|
from httpware.errors import (
|
|
12
12
|
STATUS_TO_EXCEPTION,
|
|
13
13
|
ClientStatusError,
|
|
14
|
+
NetworkError,
|
|
14
15
|
ServerStatusError,
|
|
15
16
|
TimeoutError, # noqa: A004
|
|
16
17
|
TransportError,
|
|
@@ -110,6 +111,8 @@ class AsyncClient:
|
|
|
110
111
|
raise TimeoutError(str(exc)) from exc
|
|
111
112
|
except (httpx2.InvalidURL, httpx2.CookieConflict) as exc:
|
|
112
113
|
raise TransportError(str(exc)) from exc
|
|
114
|
+
except httpx2.NetworkError as exc:
|
|
115
|
+
raise NetworkError(str(exc)) from exc
|
|
113
116
|
except httpx2.HTTPError as exc:
|
|
114
117
|
raise TransportError(str(exc)) from exc
|
|
115
118
|
except RuntimeError as exc:
|
|
@@ -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,158 @@
|
|
|
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.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
20
|
+
from httpware.middleware import Next
|
|
21
|
+
from httpware.middleware.resilience._backoff import full_jitter_delay
|
|
22
|
+
from httpware.middleware.resilience.budget import RetryBudget
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_RETRY_STATUS_CODES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
int(HTTPStatus.REQUEST_TIMEOUT),
|
|
28
|
+
int(HTTPStatus.TOO_MANY_REQUESTS),
|
|
29
|
+
int(HTTPStatus.BAD_GATEWAY),
|
|
30
|
+
int(HTTPStatus.SERVICE_UNAVAILABLE),
|
|
31
|
+
int(HTTPStatus.GATEWAY_TIMEOUT),
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"GET",
|
|
38
|
+
"HEAD",
|
|
39
|
+
"OPTIONS",
|
|
40
|
+
"PUT",
|
|
41
|
+
"DELETE",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
49
|
+
"""Parse a Retry-After header value. Returns None on malformed input."""
|
|
50
|
+
try:
|
|
51
|
+
return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
try:
|
|
55
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
|
|
59
|
+
return None
|
|
60
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
61
|
+
delta = (parsed - now).total_seconds()
|
|
62
|
+
return max(0.0, delta)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Retry:
|
|
66
|
+
"""Retry middleware. See module docstring for default policy."""
|
|
67
|
+
|
|
68
|
+
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
max_attempts: int = 3,
|
|
72
|
+
base_delay: float = 0.1,
|
|
73
|
+
max_delay: float = 5.0,
|
|
74
|
+
attempt_timeout: float | None = None,
|
|
75
|
+
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
76
|
+
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
77
|
+
respect_retry_after: bool = True,
|
|
78
|
+
budget: RetryBudget | None = None,
|
|
79
|
+
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
80
|
+
) -> None:
|
|
81
|
+
if max_attempts < 1:
|
|
82
|
+
raise ValueError(_MAX_ATTEMPTS_INVALID)
|
|
83
|
+
self.max_attempts = max_attempts
|
|
84
|
+
self.base_delay = base_delay
|
|
85
|
+
self.max_delay = max_delay
|
|
86
|
+
self.attempt_timeout = attempt_timeout
|
|
87
|
+
self.retry_status_codes = retry_status_codes
|
|
88
|
+
self.retry_methods = retry_methods
|
|
89
|
+
self.respect_retry_after = respect_retry_after
|
|
90
|
+
self.budget = budget if budget is not None else RetryBudget()
|
|
91
|
+
self._sleep = _sleep
|
|
92
|
+
|
|
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
|
|
94
|
+
"""Process a request through the retry loop. See module docstring."""
|
|
95
|
+
method_eligible = request.method.upper() in self.retry_methods
|
|
96
|
+
last_exc: BaseException | None = None
|
|
97
|
+
last_response: httpx2.Response | None = None
|
|
98
|
+
|
|
99
|
+
for attempt in range(self.max_attempts):
|
|
100
|
+
is_last = attempt + 1 >= self.max_attempts
|
|
101
|
+
self.budget.deposit()
|
|
102
|
+
try:
|
|
103
|
+
if self.attempt_timeout is not None:
|
|
104
|
+
async with asyncio.timeout(self.attempt_timeout):
|
|
105
|
+
return await next(request)
|
|
106
|
+
else:
|
|
107
|
+
return await next(request)
|
|
108
|
+
except StatusError as exc:
|
|
109
|
+
if not method_eligible or exc.response.status_code not in self.retry_status_codes:
|
|
110
|
+
raise
|
|
111
|
+
last_exc = exc
|
|
112
|
+
last_response = exc.response
|
|
113
|
+
except (NetworkError, TimeoutError) as exc:
|
|
114
|
+
if not method_eligible:
|
|
115
|
+
raise
|
|
116
|
+
last_exc = exc
|
|
117
|
+
last_response = None
|
|
118
|
+
except builtins.TimeoutError as exc:
|
|
119
|
+
wrapped = TimeoutError("attempt timed out")
|
|
120
|
+
wrapped.__cause__ = exc # set now; the retry path (last_exc = wrapped) has no `from` clause
|
|
121
|
+
if not method_eligible:
|
|
122
|
+
raise wrapped from exc
|
|
123
|
+
last_exc = wrapped
|
|
124
|
+
last_response = None
|
|
125
|
+
|
|
126
|
+
# ---- retryable failure path
|
|
127
|
+
if is_last:
|
|
128
|
+
if last_exc is None: # pragma: no cover — structural invariant from except branch
|
|
129
|
+
msg = "Retry: last_exc unset on final attempt — unreachable"
|
|
130
|
+
raise AssertionError(msg)
|
|
131
|
+
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
132
|
+
raise last_exc
|
|
133
|
+
|
|
134
|
+
if not self.budget.try_withdraw():
|
|
135
|
+
raise RetryBudgetExhaustedError(
|
|
136
|
+
last_response=last_response,
|
|
137
|
+
last_exception=last_exc,
|
|
138
|
+
attempts=attempt + 1,
|
|
139
|
+
) from last_exc
|
|
140
|
+
|
|
141
|
+
retry_after: float | None = None
|
|
142
|
+
if self.respect_retry_after and last_response is not None:
|
|
143
|
+
header = last_response.headers.get("Retry-After")
|
|
144
|
+
if header is not None:
|
|
145
|
+
retry_after = _parse_retry_after(header)
|
|
146
|
+
|
|
147
|
+
if retry_after is not None:
|
|
148
|
+
delay = min(retry_after, self.max_delay)
|
|
149
|
+
else:
|
|
150
|
+
delay = full_jitter_delay(
|
|
151
|
+
attempt,
|
|
152
|
+
base_delay=self.base_delay,
|
|
153
|
+
max_delay=self.max_delay,
|
|
154
|
+
)
|
|
155
|
+
await self._sleep(delay)
|
|
156
|
+
|
|
157
|
+
msg = "unreachable" # pragma: no cover
|
|
158
|
+
raise AssertionError(msg) # pragma: no cover
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|