httpware 0.14.0__tar.gz → 0.15.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.14.0 → httpware-0.15.0}/PKG-INFO +10 -5
- {httpware-0.14.0 → httpware-0.15.0}/pyproject.toml +15 -7
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/client.py +179 -90
- httpware-0.15.0/src/httpware/decoders/_resolver.py +64 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/errors.py +31 -13
- httpware-0.15.0/src/httpware/middleware/resilience/retry.py +297 -0
- httpware-0.14.0/src/httpware/middleware/resilience/retry.py +0 -349
- {httpware-0.14.0 → httpware-0.15.0}/README.md +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/__init__.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/redaction.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/_internal/status.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/circuit_breaker.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.14.0 → httpware-0.15.0}/src/httpware/py.typed +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
5
|
-
Keywords: http,async,
|
|
3
|
+
Version: 0.15.0
|
|
4
|
+
Summary: Python HTTP client framework with sync & async clients and built-in resilience
|
|
5
|
+
Keywords: http-client,httpx,async,asyncio,resilience,retry,circuit-breaker,bulkhead,middleware,pydantic,msgspec,python
|
|
6
6
|
Author: Artur Shiriev
|
|
7
7
|
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
8
|
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
9
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
10
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
11
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -20,8 +22,11 @@ Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
|
20
22
|
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
21
23
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
22
24
|
Requires-Python: >=3.11, <4
|
|
23
|
-
Project-URL:
|
|
24
|
-
Project-URL:
|
|
25
|
+
Project-URL: Homepage, https://httpware.modern-python.org
|
|
26
|
+
Project-URL: Documentation, https://httpware.modern-python.org
|
|
27
|
+
Project-URL: Repository, https://github.com/modern-python/httpware
|
|
28
|
+
Project-URL: Issues, https://github.com/modern-python/httpware/issues
|
|
29
|
+
Project-URL: Changelog, https://github.com/modern-python/httpware/releases
|
|
25
30
|
Provides-Extra: all
|
|
26
31
|
Provides-Extra: msgspec
|
|
27
32
|
Provides-Extra: otel
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "httpware"
|
|
3
|
-
description = "
|
|
3
|
+
description = "Python HTTP client framework with sync & async clients and built-in resilience"
|
|
4
4
|
authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
|
|
5
5
|
requires-python = ">=3.11,<4"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
keywords = [
|
|
9
|
-
"http",
|
|
9
|
+
"http-client",
|
|
10
|
+
"httpx",
|
|
10
11
|
"async",
|
|
11
|
-
"
|
|
12
|
+
"asyncio",
|
|
12
13
|
"resilience",
|
|
13
14
|
"retry",
|
|
14
15
|
"circuit-breaker",
|
|
16
|
+
"bulkhead",
|
|
15
17
|
"middleware",
|
|
16
|
-
"httpx",
|
|
17
18
|
"pydantic",
|
|
19
|
+
"msgspec",
|
|
20
|
+
"python",
|
|
18
21
|
]
|
|
19
22
|
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
20
25
|
"Programming Language :: Python :: 3.11",
|
|
21
26
|
"Programming Language :: Python :: 3.12",
|
|
22
27
|
"Programming Language :: Python :: 3.13",
|
|
@@ -26,7 +31,7 @@ classifiers = [
|
|
|
26
31
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
32
|
"Framework :: AsyncIO",
|
|
28
33
|
]
|
|
29
|
-
version = "0.
|
|
34
|
+
version = "0.15.0"
|
|
30
35
|
dependencies = [
|
|
31
36
|
"httpx2>=2.0.0,<3.0",
|
|
32
37
|
]
|
|
@@ -38,8 +43,11 @@ otel = ["opentelemetry-api>=1.20"]
|
|
|
38
43
|
all = ["httpware[pydantic,msgspec,otel]"]
|
|
39
44
|
|
|
40
45
|
[project.urls]
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
Homepage = "https://httpware.modern-python.org"
|
|
47
|
+
Documentation = "https://httpware.modern-python.org"
|
|
48
|
+
Repository = "https://github.com/modern-python/httpware"
|
|
49
|
+
Issues = "https://github.com/modern-python/httpware/issues"
|
|
50
|
+
Changelog = "https://github.com/modern-python/httpware/releases"
|
|
43
51
|
|
|
44
52
|
[build-system]
|
|
45
53
|
requires = ["uv_build>=0.11,<1.0"]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
4
|
import typing
|
|
5
|
-
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
6
6
|
from http import HTTPStatus
|
|
7
7
|
|
|
8
8
|
import httpx2
|
|
@@ -16,7 +16,8 @@ from httpware._internal.status import (
|
|
|
16
16
|
_raise_on_status_error,
|
|
17
17
|
)
|
|
18
18
|
from httpware.decoders import ResponseDecoder
|
|
19
|
-
from httpware.
|
|
19
|
+
from httpware.decoders._resolver import _DecoderResolver
|
|
20
|
+
from httpware.errors import ResponseTooLargeError, TransportError
|
|
20
21
|
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
|
|
21
22
|
from httpware.middleware.chain import compose, compose_async
|
|
22
23
|
|
|
@@ -31,6 +32,15 @@ _HTTPX2_CLIENT_CONFLICT_MESSAGE = (
|
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
_MAX_RESPONSE_BODY_BYTES_INVALID = "max_response_body_bytes must be >= 1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_max_response_body_bytes(cap: int | None) -> None:
|
|
39
|
+
"""Reject a non-None cap below 1. None means unbounded (the default)."""
|
|
40
|
+
if cap is not None and cap < 1:
|
|
41
|
+
raise ValueError(_MAX_RESPONSE_BODY_BYTES_INVALID)
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
def _parse_content_length(raw: str | None) -> int | None:
|
|
35
45
|
"""Return a non-negative int Content-Length, or None for missing/garbage. Never raises."""
|
|
36
46
|
if raw is None:
|
|
@@ -42,6 +52,123 @@ def _parse_content_length(raw: str | None) -> int | None:
|
|
|
42
52
|
return value if value >= 0 else None
|
|
43
53
|
|
|
44
54
|
|
|
55
|
+
class _CapExceeded(Exception): # noqa: N818 — internal control-flow signal, not a user-facing error
|
|
56
|
+
"""Internal signal: decoded bytes crossed the cap mid-read. Carries bytes read so far."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, read: int) -> None:
|
|
59
|
+
self.read = read
|
|
60
|
+
super().__init__(f"decoded body exceeded cap after {read} bytes")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _accumulate_capped(chunks: typing.Iterable[bytes], cap: int) -> bytes:
|
|
64
|
+
"""Concatenate `chunks`, raising `_CapExceeded` the moment the running total exceeds `cap`.
|
|
65
|
+
|
|
66
|
+
Counts decoded bytes (the in-memory footprint). Grown in a single bytearray
|
|
67
|
+
so there is no transient list-plus-join double allocation.
|
|
68
|
+
"""
|
|
69
|
+
buf = bytearray()
|
|
70
|
+
for chunk in chunks:
|
|
71
|
+
buf += chunk
|
|
72
|
+
if len(buf) > cap:
|
|
73
|
+
raise _CapExceeded(read=len(buf))
|
|
74
|
+
return bytes(buf)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_extensions(extensions: Mapping[str, typing.Any]) -> dict[str, typing.Any]:
|
|
78
|
+
"""Copy response extensions, dropping the now-stale `network_stream`.
|
|
79
|
+
|
|
80
|
+
The rebuilt buffered Response never touches its network stream, so carrying a
|
|
81
|
+
consumed/closed one wholesale is sloppy. `http_version`/`reason_phrase` and
|
|
82
|
+
any other keys are preserved.
|
|
83
|
+
"""
|
|
84
|
+
return {key: value for key, value in extensions.items() if key != "network_stream"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Headers describing the wire encoding of the body. The accumulator yields the
|
|
88
|
+
# DECODED body, so these no longer apply; httpx2 recomputes content-length from
|
|
89
|
+
# the buffered content. Carrying content-encoding forward makes httpx2 try to
|
|
90
|
+
# re-decode already-decoded bytes and raise.
|
|
91
|
+
_WIRE_BODY_HEADERS = ("content-encoding", "content-length", "transfer-encoding")
|
|
92
|
+
_BODILESS_STATUS = frozenset({HTTPStatus.NO_CONTENT, HTTPStatus.NOT_MODIFIED}) # 204, 304
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _buffered_headers(headers: httpx2.Headers) -> httpx2.Headers:
|
|
96
|
+
"""Copy `headers`, stripping wire-encoding headers stale after decoding+buffering."""
|
|
97
|
+
out = httpx2.Headers(headers)
|
|
98
|
+
for name in _WIRE_BODY_HEADERS:
|
|
99
|
+
if name in out:
|
|
100
|
+
del out[name]
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _response_has_body(method: str, status_code: int) -> bool:
|
|
105
|
+
"""Whether a response carries a message body (RFC 9110 §6.4.1).
|
|
106
|
+
|
|
107
|
+
HEAD responses and 204/304 never have a body regardless of a declared
|
|
108
|
+
Content-Length, so they must never trip the cap.
|
|
109
|
+
"""
|
|
110
|
+
return method.upper() != "HEAD" and status_code not in _BODILESS_STATUS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _read_capped(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
|
|
114
|
+
"""Buffer a streaming sync `response` under `cap` decoded bytes; return a buffered Response.
|
|
115
|
+
|
|
116
|
+
Raises `ResponseTooLargeError` (reason="declared") if the declared
|
|
117
|
+
Content-Length already exceeds `cap` — before any byte is read — and
|
|
118
|
+
(reason="streamed") if the decoded body crosses `cap` mid-read. Does not
|
|
119
|
+
close `response`; the caller owns the stream lifecycle.
|
|
120
|
+
"""
|
|
121
|
+
if not _response_has_body(request.method, response.status_code):
|
|
122
|
+
response.read() # empty body; preserve the original response (and its headers)
|
|
123
|
+
return response
|
|
124
|
+
content_length = _parse_content_length(response.headers.get("content-length"))
|
|
125
|
+
if content_length is not None and content_length > cap:
|
|
126
|
+
raise ResponseTooLargeError(
|
|
127
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
content = _accumulate_capped(response.iter_bytes(), cap)
|
|
131
|
+
except _CapExceeded:
|
|
132
|
+
raise ResponseTooLargeError(
|
|
133
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
|
|
134
|
+
) from None
|
|
135
|
+
return httpx2.Response(
|
|
136
|
+
status_code=response.status_code,
|
|
137
|
+
headers=_buffered_headers(response.headers),
|
|
138
|
+
content=content,
|
|
139
|
+
request=request,
|
|
140
|
+
extensions=_safe_extensions(response.extensions),
|
|
141
|
+
history=response.history,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _read_capped_async(response: httpx2.Response, cap: int, request: httpx2.Request) -> httpx2.Response:
|
|
146
|
+
"""Async mirror of `_read_capped` (counts decoded bytes from `aiter_bytes`)."""
|
|
147
|
+
if not _response_has_body(request.method, response.status_code):
|
|
148
|
+
await response.aread() # empty body; preserve the original response (and its headers)
|
|
149
|
+
return response
|
|
150
|
+
content_length = _parse_content_length(response.headers.get("content-length"))
|
|
151
|
+
if content_length is not None and content_length > cap:
|
|
152
|
+
raise ResponseTooLargeError(
|
|
153
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="declared"
|
|
154
|
+
)
|
|
155
|
+
buf = bytearray()
|
|
156
|
+
async for chunk in response.aiter_bytes():
|
|
157
|
+
buf += chunk
|
|
158
|
+
if len(buf) > cap:
|
|
159
|
+
raise ResponseTooLargeError(
|
|
160
|
+
status_code=response.status_code, limit=cap, content_length=content_length, reason="streamed"
|
|
161
|
+
)
|
|
162
|
+
return httpx2.Response(
|
|
163
|
+
status_code=response.status_code,
|
|
164
|
+
headers=_buffered_headers(response.headers),
|
|
165
|
+
content=bytes(buf),
|
|
166
|
+
request=request,
|
|
167
|
+
extensions=_safe_extensions(response.extensions),
|
|
168
|
+
history=response.history,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
45
172
|
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
|
|
46
173
|
"""Construct the default decoder tuple based on installed extras.
|
|
47
174
|
|
|
@@ -93,7 +220,7 @@ class AsyncClient:
|
|
|
93
220
|
_decoders: tuple[ResponseDecoder, ...]
|
|
94
221
|
_user_middleware: tuple[AsyncMiddleware, ...]
|
|
95
222
|
_dispatch: AsyncNext
|
|
96
|
-
|
|
223
|
+
_max_response_body_bytes: int | None
|
|
97
224
|
|
|
98
225
|
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
|
|
99
226
|
self,
|
|
@@ -108,8 +235,9 @@ class AsyncClient:
|
|
|
108
235
|
httpx2_client: httpx2.AsyncClient | None = None,
|
|
109
236
|
decoders: Sequence[ResponseDecoder] | None = None,
|
|
110
237
|
middleware: Sequence[AsyncMiddleware] = (),
|
|
111
|
-
|
|
238
|
+
max_response_body_bytes: int | None = None,
|
|
112
239
|
) -> None:
|
|
240
|
+
_validate_max_response_body_bytes(max_response_body_bytes)
|
|
113
241
|
if httpx2_client is not None:
|
|
114
242
|
forwarded = {
|
|
115
243
|
"base_url": base_url,
|
|
@@ -144,21 +272,23 @@ class AsyncClient:
|
|
|
144
272
|
self._owns_client = True
|
|
145
273
|
|
|
146
274
|
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
275
|
+
self._decoder_resolver = _DecoderResolver(self._decoders)
|
|
147
276
|
self._user_middleware = tuple(middleware)
|
|
148
277
|
self._dispatch = compose_async(self._user_middleware, self._terminal)
|
|
149
|
-
self.
|
|
150
|
-
|
|
151
|
-
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
152
|
-
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
153
|
-
for decoder in self._decoders:
|
|
154
|
-
if decoder.can_decode(model):
|
|
155
|
-
return decoder
|
|
156
|
-
return None
|
|
278
|
+
self._max_response_body_bytes = max_response_body_bytes
|
|
157
279
|
|
|
158
280
|
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
281
|
+
cap = self._max_response_body_bytes
|
|
159
282
|
try:
|
|
160
283
|
async with _httpx2_exception_mapper():
|
|
161
|
-
|
|
284
|
+
if cap is None:
|
|
285
|
+
response = await self._httpx2_client.send(request)
|
|
286
|
+
else:
|
|
287
|
+
streaming = await self._httpx2_client.send(request, stream=True)
|
|
288
|
+
try:
|
|
289
|
+
response = await _read_capped_async(streaming, cap, request)
|
|
290
|
+
finally:
|
|
291
|
+
await streaming.aclose()
|
|
162
292
|
except RuntimeError as exc:
|
|
163
293
|
if self._httpx2_client.is_closed:
|
|
164
294
|
raise TransportError(str(exc)) from exc
|
|
@@ -182,18 +312,9 @@ class AsyncClient:
|
|
|
182
312
|
if response_model is None:
|
|
183
313
|
return await self._dispatch(request)
|
|
184
314
|
|
|
185
|
-
|
|
186
|
-
if decoder is None:
|
|
187
|
-
raise MissingDecoderError(
|
|
188
|
-
model=response_model,
|
|
189
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
190
|
-
)
|
|
191
|
-
|
|
315
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
192
316
|
response = await self._dispatch(request)
|
|
193
|
-
|
|
194
|
-
return decoder.decode(response.content, response_model)
|
|
195
|
-
except Exception as exc:
|
|
196
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
317
|
+
return bound.decode(response)
|
|
197
318
|
|
|
198
319
|
async def send_with_response(
|
|
199
320
|
self,
|
|
@@ -210,19 +331,9 @@ class AsyncClient:
|
|
|
210
331
|
Not for streaming responses — decodes ``response.content``, which
|
|
211
332
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
212
333
|
"""
|
|
213
|
-
|
|
214
|
-
if decoder is None:
|
|
215
|
-
raise MissingDecoderError(
|
|
216
|
-
model=response_model,
|
|
217
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
218
|
-
)
|
|
219
|
-
|
|
334
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
220
335
|
response = await self._dispatch(request)
|
|
221
|
-
|
|
222
|
-
decoded = decoder.decode(response.content, response_model)
|
|
223
|
-
except Exception as exc:
|
|
224
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
225
|
-
return response, decoded
|
|
336
|
+
return response, bound.decode(response)
|
|
226
337
|
|
|
227
338
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
228
339
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
@@ -1039,16 +1150,13 @@ class AsyncClient:
|
|
|
1039
1150
|
|
|
1040
1151
|
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
|
|
1041
1152
|
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
)
|
|
1050
|
-
await response.aread() # pre-read body so exc.response.content works
|
|
1051
|
-
_raise_on_status_error(response)
|
|
1153
|
+
cap = self._max_response_body_bytes
|
|
1154
|
+
if cap is None:
|
|
1155
|
+
await response.aread() # pre-read body so exc.response.content works
|
|
1156
|
+
_raise_on_status_error(response)
|
|
1157
|
+
else:
|
|
1158
|
+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
|
|
1159
|
+
_raise_on_status_error(await _read_capped_async(response, cap, response.request))
|
|
1052
1160
|
yield response
|
|
1053
1161
|
|
|
1054
1162
|
async def __aenter__(self) -> typing.Self:
|
|
@@ -1084,7 +1192,7 @@ class Client:
|
|
|
1084
1192
|
_decoders: tuple[ResponseDecoder, ...]
|
|
1085
1193
|
_user_middleware: tuple[Middleware, ...]
|
|
1086
1194
|
_dispatch: Next
|
|
1087
|
-
|
|
1195
|
+
_max_response_body_bytes: int | None
|
|
1088
1196
|
|
|
1089
1197
|
def __init__( # noqa: PLR0913 — wide constructor is the cost of a single-call API
|
|
1090
1198
|
self,
|
|
@@ -1099,8 +1207,9 @@ class Client:
|
|
|
1099
1207
|
httpx2_client: httpx2.Client | None = None,
|
|
1100
1208
|
decoders: Sequence[ResponseDecoder] | None = None,
|
|
1101
1209
|
middleware: Sequence[Middleware] = (),
|
|
1102
|
-
|
|
1210
|
+
max_response_body_bytes: int | None = None,
|
|
1103
1211
|
) -> None:
|
|
1212
|
+
_validate_max_response_body_bytes(max_response_body_bytes)
|
|
1104
1213
|
if httpx2_client is not None:
|
|
1105
1214
|
forwarded = {
|
|
1106
1215
|
"base_url": base_url,
|
|
@@ -1135,21 +1244,23 @@ class Client:
|
|
|
1135
1244
|
self._owns_client = True
|
|
1136
1245
|
|
|
1137
1246
|
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
1247
|
+
self._decoder_resolver = _DecoderResolver(self._decoders)
|
|
1138
1248
|
self._user_middleware = tuple(middleware)
|
|
1139
1249
|
self._dispatch = compose(self._user_middleware, self._terminal)
|
|
1140
|
-
self.
|
|
1141
|
-
|
|
1142
|
-
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
1143
|
-
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
1144
|
-
for decoder in self._decoders:
|
|
1145
|
-
if decoder.can_decode(model):
|
|
1146
|
-
return decoder
|
|
1147
|
-
return None
|
|
1250
|
+
self._max_response_body_bytes = max_response_body_bytes
|
|
1148
1251
|
|
|
1149
1252
|
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
1253
|
+
cap = self._max_response_body_bytes
|
|
1150
1254
|
try:
|
|
1151
1255
|
with _httpx2_exception_mapper_sync():
|
|
1152
|
-
|
|
1256
|
+
if cap is None:
|
|
1257
|
+
response = self._httpx2_client.send(request)
|
|
1258
|
+
else:
|
|
1259
|
+
streaming = self._httpx2_client.send(request, stream=True)
|
|
1260
|
+
try:
|
|
1261
|
+
response = _read_capped(streaming, cap, request)
|
|
1262
|
+
finally:
|
|
1263
|
+
streaming.close()
|
|
1153
1264
|
except RuntimeError as exc:
|
|
1154
1265
|
if self._httpx2_client.is_closed:
|
|
1155
1266
|
raise TransportError(str(exc)) from exc
|
|
@@ -1197,18 +1308,9 @@ class Client:
|
|
|
1197
1308
|
if response_model is None:
|
|
1198
1309
|
return self._dispatch(request)
|
|
1199
1310
|
|
|
1200
|
-
|
|
1201
|
-
if decoder is None:
|
|
1202
|
-
raise MissingDecoderError(
|
|
1203
|
-
model=response_model,
|
|
1204
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
1205
|
-
)
|
|
1206
|
-
|
|
1311
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
1207
1312
|
response = self._dispatch(request)
|
|
1208
|
-
|
|
1209
|
-
return decoder.decode(response.content, response_model)
|
|
1210
|
-
except Exception as exc:
|
|
1211
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
1313
|
+
return bound.decode(response)
|
|
1212
1314
|
|
|
1213
1315
|
def send_with_response(
|
|
1214
1316
|
self,
|
|
@@ -1225,19 +1327,9 @@ class Client:
|
|
|
1225
1327
|
Not for streaming responses — decodes ``response.content``, which
|
|
1226
1328
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
1227
1329
|
"""
|
|
1228
|
-
|
|
1229
|
-
if decoder is None:
|
|
1230
|
-
raise MissingDecoderError(
|
|
1231
|
-
model=response_model,
|
|
1232
|
-
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
1233
|
-
)
|
|
1234
|
-
|
|
1330
|
+
bound = self._decoder_resolver.resolve(response_model)
|
|
1235
1331
|
response = self._dispatch(request)
|
|
1236
|
-
|
|
1237
|
-
decoded = decoder.decode(response.content, response_model)
|
|
1238
|
-
except Exception as exc:
|
|
1239
|
-
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
1240
|
-
return response, decoded
|
|
1332
|
+
return response, bound.decode(response)
|
|
1241
1333
|
|
|
1242
1334
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
1243
1335
|
"""Delegate request construction to the wrapped httpx2.Client."""
|
|
@@ -2052,14 +2144,11 @@ class Client:
|
|
|
2052
2144
|
|
|
2053
2145
|
with _httpx2_exception_mapper_sync(), self._httpx2_client.stream(method, url, **kwargs) as response:
|
|
2054
2146
|
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
)
|
|
2063
|
-
response.read() # pre-read body so exc.response.content works
|
|
2064
|
-
_raise_on_status_error(response)
|
|
2147
|
+
cap = self._max_response_body_bytes
|
|
2148
|
+
if cap is None:
|
|
2149
|
+
response.read() # pre-read body so exc.response.content works
|
|
2150
|
+
_raise_on_status_error(response)
|
|
2151
|
+
else:
|
|
2152
|
+
# Bound the error pre-read; raises ResponseTooLargeError when over cap.
|
|
2153
|
+
_raise_on_status_error(_read_capped(response, cap, response.request))
|
|
2065
2154
|
yield response
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The Seam B orchestrator: resolve a response_model to a claiming decoder, then decode.
|
|
2
|
+
|
|
3
|
+
`_DecoderResolver` walks the client's frozen `_decoders` tuple and returns the
|
|
4
|
+
first decoder whose `can_decode` claims the model, bound to that model as a
|
|
5
|
+
`_BoundDecoder`. Resolution (and the pre-flight `MissingDecoderError`) is a
|
|
6
|
+
separate step from decoding because the HTTP call happens between them: the
|
|
7
|
+
client calls `resolve` before `_dispatch` — so a missing decoder fails before
|
|
8
|
+
the request goes out — and `_BoundDecoder.decode` after the response arrives.
|
|
9
|
+
|
|
10
|
+
Both clients hold one `_DecoderResolver`; it is fully synchronous, so there is
|
|
11
|
+
no sync/async split. See architecture/decoders.md for the full Seam B contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Generic, TypeVar
|
|
15
|
+
|
|
16
|
+
import httpx2
|
|
17
|
+
|
|
18
|
+
from httpware.decoders import ResponseDecoder
|
|
19
|
+
from httpware.errors import DecodeError, MissingDecoderError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BoundDecoder(Generic[T]):
|
|
26
|
+
"""A `ResponseDecoder` sealed to the `model` it will decode into.
|
|
27
|
+
|
|
28
|
+
Binding the decoder and model together at resolve time makes a
|
|
29
|
+
decoder/model mismatch unrepresentable: the caller supplies only the
|
|
30
|
+
response. Decode failures are wrapped as `DecodeError` (the Seam B
|
|
31
|
+
contract — implementers never raise it directly).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, decoder: ResponseDecoder, model: type[T]) -> None:
|
|
35
|
+
self._decoder = decoder
|
|
36
|
+
self._model = model
|
|
37
|
+
|
|
38
|
+
def decode(self, response: httpx2.Response) -> T:
|
|
39
|
+
"""Decode `response.content` into `model`, wrapping any failure as `DecodeError`."""
|
|
40
|
+
try:
|
|
41
|
+
return self._decoder.decode(response.content, self._model)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise DecodeError(response=response, model=self._model, original=exc) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DecoderResolver:
|
|
47
|
+
"""Resolves a `response_model` to the first claiming decoder in a frozen list."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, decoders: tuple[ResponseDecoder, ...]) -> None:
|
|
50
|
+
self._decoders = decoders
|
|
51
|
+
|
|
52
|
+
def resolve(self, model: type[T]) -> _BoundDecoder[T]:
|
|
53
|
+
"""Return the first decoder claiming `model`, bound to it.
|
|
54
|
+
|
|
55
|
+
Raises `MissingDecoderError` when no registered decoder claims `model`.
|
|
56
|
+
Called before the HTTP call, so the failure is pre-flight.
|
|
57
|
+
"""
|
|
58
|
+
for decoder in self._decoders:
|
|
59
|
+
if decoder.can_decode(model):
|
|
60
|
+
return _BoundDecoder(decoder, model)
|
|
61
|
+
raise MissingDecoderError(
|
|
62
|
+
model=model,
|
|
63
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
64
|
+
)
|
|
@@ -15,7 +15,7 @@ authors must redact those before logging.
|
|
|
15
15
|
|
|
16
16
|
import builtins
|
|
17
17
|
from collections.abc import Mapping
|
|
18
|
-
from typing import Any
|
|
18
|
+
from typing import Any, Literal
|
|
19
19
|
|
|
20
20
|
import httpx2
|
|
21
21
|
|
|
@@ -320,33 +320,51 @@ def _reconstruct_response_too_large(
|
|
|
320
320
|
status_code: int,
|
|
321
321
|
limit: int,
|
|
322
322
|
content_length: int | None,
|
|
323
|
+
reason: 'Literal["declared", "streamed"]',
|
|
323
324
|
) -> "ResponseTooLargeError":
|
|
324
|
-
return cls(status_code=status_code, limit=limit, content_length=content_length)
|
|
325
|
+
return cls(status_code=status_code, limit=limit, content_length=content_length, reason=reason)
|
|
325
326
|
|
|
326
327
|
|
|
327
328
|
class ResponseTooLargeError(ClientError):
|
|
328
|
-
"""Raised when
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
"""Raised when a response body exceeds the client's max_response_body_bytes cap.
|
|
330
|
+
|
|
331
|
+
Status-agnostic: fires on any non-streaming send() and on stream()'s internal
|
|
332
|
+
error pre-read, counting DECODED bytes. Only raised when
|
|
333
|
+
max_response_body_bytes is set (opt-in). `reason` discriminates the two trip
|
|
334
|
+
modes:
|
|
335
|
+
|
|
336
|
+
- "declared": the response's declared Content-Length already exceeds the cap,
|
|
337
|
+
so the body is rejected BEFORE a byte is read (`content_length` holds it).
|
|
338
|
+
- "streamed": the decoded body crossed the cap mid-read (the chunked or
|
|
339
|
+
compression-bomb case); `content_length` is whatever the server declared
|
|
340
|
+
and is unrelated to the cap. The true oversized size is unknown by design.
|
|
333
341
|
"""
|
|
334
342
|
|
|
335
343
|
status_code: int
|
|
336
344
|
limit: int
|
|
337
345
|
content_length: int | None
|
|
346
|
+
reason: Literal["declared", "streamed"]
|
|
338
347
|
|
|
339
|
-
def __init__(
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
status_code: int,
|
|
352
|
+
limit: int,
|
|
353
|
+
content_length: int | None,
|
|
354
|
+
reason: Literal["declared", "streamed"],
|
|
355
|
+
) -> None:
|
|
340
356
|
self.status_code = status_code
|
|
341
357
|
self.limit = limit
|
|
342
358
|
self.content_length = content_length
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
f"content_length={content_length} exceeds
|
|
346
|
-
|
|
359
|
+
self.reason = reason
|
|
360
|
+
if reason == "declared":
|
|
361
|
+
detail = f"declared content_length={content_length} exceeds max_response_body_bytes={limit}"
|
|
362
|
+
else:
|
|
363
|
+
detail = f"decoded body exceeded max_response_body_bytes={limit}"
|
|
364
|
+
super().__init__(f"response body too large: status={status_code} {detail}")
|
|
347
365
|
|
|
348
366
|
def __reduce__(self) -> tuple[Any, ...]:
|
|
349
367
|
return (
|
|
350
368
|
_reconstruct_response_too_large,
|
|
351
|
-
(type(self), self.status_code, self.limit, self.content_length),
|
|
369
|
+
(type(self), self.status_code, self.limit, self.content_length, self.reason),
|
|
352
370
|
)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""AsyncRetry + 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 client terminal raises StatusError subclasses on 4xx/5xx,
|
|
6
|
+
so the retry middleware catches StatusError and inspects exc.response.status_code. The
|
|
7
|
+
original StatusError subclass is re-raised unwrapped on exhaustion, with a PEP 678 note
|
|
8
|
+
added.
|
|
9
|
+
|
|
10
|
+
The decision logic lives in the lock-free, stateless _RetryPolicy, shared by both
|
|
11
|
+
wrappers (mirroring _CircuitBreakerState). AsyncRetry and Retry are thin loop drivers:
|
|
12
|
+
they own the attempt loop, the terminal call, and the sleep, and differ only in
|
|
13
|
+
``await next`` vs ``next`` and ``asyncio.sleep`` vs ``time.sleep``. _RetryPolicy holds
|
|
14
|
+
the immutable config plus the shared RetryBudget; per-attempt state stays as wrapper
|
|
15
|
+
locals, so a single instance is safe across the concurrent requests it serves.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import datetime
|
|
20
|
+
import email.utils
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from http import HTTPStatus
|
|
25
|
+
|
|
26
|
+
import httpx2
|
|
27
|
+
|
|
28
|
+
from httpware._internal.observability import _emit_event
|
|
29
|
+
from httpware._internal.status import STREAMING_BODY_MARKER
|
|
30
|
+
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
31
|
+
from httpware.middleware import AsyncNext, Next
|
|
32
|
+
from httpware.middleware.resilience._backoff import full_jitter_delay
|
|
33
|
+
from httpware.middleware.resilience.budget import RetryBudget
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DEFAULT_RETRY_STATUS_CODES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
int(HTTPStatus.REQUEST_TIMEOUT),
|
|
39
|
+
int(HTTPStatus.TOO_MANY_REQUESTS),
|
|
40
|
+
int(HTTPStatus.BAD_GATEWAY),
|
|
41
|
+
int(HTTPStatus.SERVICE_UNAVAILABLE),
|
|
42
|
+
int(HTTPStatus.GATEWAY_TIMEOUT),
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"GET",
|
|
49
|
+
"HEAD",
|
|
50
|
+
"OPTIONS",
|
|
51
|
+
"PUT",
|
|
52
|
+
"DELETE",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Catch surface for both wrappers. Narrow by design: anything not in this tuple
|
|
57
|
+
# (e.g. httpx2.InvalidURL, programming errors) propagates untouched.
|
|
58
|
+
_RETRYABLE_EXCEPTIONS = (StatusError, NetworkError, TimeoutError)
|
|
59
|
+
|
|
60
|
+
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
61
|
+
_STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
|
|
62
|
+
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE = (
|
|
63
|
+
"httpware: Retry-After ({retry_after}s) exceeded max_delay ({max_delay}s); giving up"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_LOGGER = logging.getLogger("httpware.retry")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
70
|
+
"""Parse a Retry-After header value. Returns None on malformed input."""
|
|
71
|
+
try:
|
|
72
|
+
return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
|
|
73
|
+
except (ValueError, OverflowError):
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
|
|
80
|
+
return None
|
|
81
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
82
|
+
delta = (parsed - now).total_seconds()
|
|
83
|
+
return max(0.0, delta)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _RetryPolicy:
|
|
87
|
+
"""Stateless retry decision module shared by AsyncRetry + Retry.
|
|
88
|
+
|
|
89
|
+
Holds the immutable retry config plus the shared RetryBudget and nothing
|
|
90
|
+
per-call mutable, so a single instance is safe across concurrent requests.
|
|
91
|
+
``decide`` is synchronous and does the whole decision: it returns the delay
|
|
92
|
+
to sleep before the next attempt, or raises the terminal exception (having
|
|
93
|
+
added the PEP 678 note and emitted the event). It is invoked from inside the
|
|
94
|
+
wrapper's ``except`` block, so exception chaining behaves as a direct raise.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
max_attempts: int,
|
|
101
|
+
base_delay: float,
|
|
102
|
+
max_delay: float,
|
|
103
|
+
retry_status_codes: frozenset[int],
|
|
104
|
+
retry_methods: frozenset[str],
|
|
105
|
+
respect_retry_after: bool,
|
|
106
|
+
budget: RetryBudget | None,
|
|
107
|
+
) -> None:
|
|
108
|
+
if max_attempts < 1:
|
|
109
|
+
raise ValueError(_MAX_ATTEMPTS_INVALID)
|
|
110
|
+
self.max_attempts = max_attempts
|
|
111
|
+
self.base_delay = base_delay
|
|
112
|
+
self.max_delay = max_delay
|
|
113
|
+
self.retry_status_codes = retry_status_codes
|
|
114
|
+
self.retry_methods = retry_methods
|
|
115
|
+
self.respect_retry_after = respect_retry_after
|
|
116
|
+
self.budget = budget if budget is not None else RetryBudget()
|
|
117
|
+
|
|
118
|
+
def decide( # noqa: C901 — complexity budget: classification + streaming-body refusal + exhaustion + Retry-After branch + budget gate + backoff
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
attempt: int,
|
|
122
|
+
request: httpx2.Request,
|
|
123
|
+
exc: BaseException,
|
|
124
|
+
) -> float:
|
|
125
|
+
"""Decide the next action after a retryable failure on `attempt`.
|
|
126
|
+
|
|
127
|
+
Returns the delay to sleep before retrying, or raises the terminal
|
|
128
|
+
exception. `exc` is the currently-handled exception (one of
|
|
129
|
+
_RETRYABLE_EXCEPTIONS); see the class docstring for chaining semantics.
|
|
130
|
+
"""
|
|
131
|
+
method_eligible = request.method.upper() in self.retry_methods
|
|
132
|
+
if isinstance(exc, StatusError):
|
|
133
|
+
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
134
|
+
if not method_eligible or not retryable_status:
|
|
135
|
+
raise exc
|
|
136
|
+
last_response: httpx2.Response | None = exc.response
|
|
137
|
+
else: # NetworkError | TimeoutError
|
|
138
|
+
if not method_eligible:
|
|
139
|
+
raise exc
|
|
140
|
+
last_response = None
|
|
141
|
+
|
|
142
|
+
# ---- retryable failure path
|
|
143
|
+
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
144
|
+
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(exc).__qualname__,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
raise exc
|
|
157
|
+
|
|
158
|
+
if attempt + 1 >= self.max_attempts:
|
|
159
|
+
exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
160
|
+
_emit_event(
|
|
161
|
+
_LOGGER,
|
|
162
|
+
"retry.giving_up",
|
|
163
|
+
level=logging.WARNING,
|
|
164
|
+
message=f"retry gave up after {attempt + 1} attempts",
|
|
165
|
+
attributes={
|
|
166
|
+
"attempts": attempt + 1,
|
|
167
|
+
"method": request.method,
|
|
168
|
+
"url": str(request.url),
|
|
169
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
170
|
+
"last_exception_type": type(exc).__qualname__,
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
raise exc
|
|
174
|
+
|
|
175
|
+
retry_after: float | None = None
|
|
176
|
+
if self.respect_retry_after and last_response is not None:
|
|
177
|
+
header = last_response.headers.get("Retry-After")
|
|
178
|
+
if header is not None:
|
|
179
|
+
retry_after = _parse_retry_after(header)
|
|
180
|
+
|
|
181
|
+
if retry_after is not None and retry_after > self.max_delay:
|
|
182
|
+
exc.add_note(
|
|
183
|
+
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
|
|
184
|
+
retry_after=retry_after,
|
|
185
|
+
max_delay=self.max_delay,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
raise exc
|
|
189
|
+
|
|
190
|
+
if not self.budget.try_withdraw():
|
|
191
|
+
_emit_event(
|
|
192
|
+
_LOGGER,
|
|
193
|
+
"retry.budget_refused",
|
|
194
|
+
level=logging.WARNING,
|
|
195
|
+
message=f"retry budget refused after {attempt + 1} attempts",
|
|
196
|
+
attributes={
|
|
197
|
+
"attempts": attempt + 1,
|
|
198
|
+
"method": request.method,
|
|
199
|
+
"url": str(request.url),
|
|
200
|
+
"last_status": last_response.status_code if last_response is not None else None,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
raise RetryBudgetExhaustedError(
|
|
204
|
+
last_response=last_response,
|
|
205
|
+
last_exception=exc,
|
|
206
|
+
attempts=attempt + 1,
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
if retry_after is not None:
|
|
210
|
+
return retry_after
|
|
211
|
+
return full_jitter_delay(
|
|
212
|
+
attempt,
|
|
213
|
+
base_delay=self.base_delay,
|
|
214
|
+
max_delay=self.max_delay,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AsyncRetry:
|
|
219
|
+
"""Async retry middleware. See the module docstring for the default policy."""
|
|
220
|
+
|
|
221
|
+
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
max_attempts: int = 3,
|
|
225
|
+
base_delay: float = 0.1,
|
|
226
|
+
max_delay: float = 5.0,
|
|
227
|
+
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
228
|
+
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
229
|
+
respect_retry_after: bool = True,
|
|
230
|
+
budget: RetryBudget | None = None,
|
|
231
|
+
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
232
|
+
) -> None:
|
|
233
|
+
self._policy = _RetryPolicy(
|
|
234
|
+
max_attempts=max_attempts,
|
|
235
|
+
base_delay=base_delay,
|
|
236
|
+
max_delay=max_delay,
|
|
237
|
+
retry_status_codes=retry_status_codes,
|
|
238
|
+
retry_methods=retry_methods,
|
|
239
|
+
respect_retry_after=respect_retry_after,
|
|
240
|
+
budget=budget,
|
|
241
|
+
)
|
|
242
|
+
self.budget = self._policy.budget
|
|
243
|
+
self._sleep = _sleep
|
|
244
|
+
|
|
245
|
+
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
246
|
+
"""Process a request through the retry loop. See module docstring."""
|
|
247
|
+
self.budget.deposit()
|
|
248
|
+
for attempt in range(self._policy.max_attempts):
|
|
249
|
+
try:
|
|
250
|
+
return await next(request)
|
|
251
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
252
|
+
delay = self._policy.decide(attempt=attempt, request=request, exc=exc)
|
|
253
|
+
await self._sleep(delay)
|
|
254
|
+
|
|
255
|
+
msg = "unreachable" # pragma: no cover
|
|
256
|
+
raise AssertionError(msg) # pragma: no cover
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Retry:
|
|
260
|
+
"""Sync retry middleware. Mirror of AsyncRetry; uses time.sleep instead of asyncio.sleep."""
|
|
261
|
+
|
|
262
|
+
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
263
|
+
self,
|
|
264
|
+
*,
|
|
265
|
+
max_attempts: int = 3,
|
|
266
|
+
base_delay: float = 0.1,
|
|
267
|
+
max_delay: float = 5.0,
|
|
268
|
+
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
269
|
+
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
270
|
+
respect_retry_after: bool = True,
|
|
271
|
+
budget: RetryBudget | None = None,
|
|
272
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
273
|
+
) -> None:
|
|
274
|
+
self._policy = _RetryPolicy(
|
|
275
|
+
max_attempts=max_attempts,
|
|
276
|
+
base_delay=base_delay,
|
|
277
|
+
max_delay=max_delay,
|
|
278
|
+
retry_status_codes=retry_status_codes,
|
|
279
|
+
retry_methods=retry_methods,
|
|
280
|
+
respect_retry_after=respect_retry_after,
|
|
281
|
+
budget=budget,
|
|
282
|
+
)
|
|
283
|
+
self.budget = self._policy.budget
|
|
284
|
+
self._sleep = _sleep
|
|
285
|
+
|
|
286
|
+
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002
|
|
287
|
+
"""Process a request through the sync retry loop. See AsyncRetry for full contract."""
|
|
288
|
+
self.budget.deposit()
|
|
289
|
+
for attempt in range(self._policy.max_attempts):
|
|
290
|
+
try:
|
|
291
|
+
return next(request)
|
|
292
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
293
|
+
delay = self._policy.decide(attempt=attempt, request=request, exc=exc)
|
|
294
|
+
self._sleep(delay)
|
|
295
|
+
|
|
296
|
+
msg = "unreachable" # pragma: no cover
|
|
297
|
+
raise AssertionError(msg) # pragma: no cover
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
"""AsyncRetry 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 AsyncRetry 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 datetime
|
|
12
|
-
import email.utils
|
|
13
|
-
import logging
|
|
14
|
-
import time
|
|
15
|
-
from collections.abc import Awaitable, Callable
|
|
16
|
-
from http import HTTPStatus
|
|
17
|
-
|
|
18
|
-
import httpx2
|
|
19
|
-
|
|
20
|
-
from httpware._internal.observability import _emit_event
|
|
21
|
-
from httpware._internal.status import STREAMING_BODY_MARKER
|
|
22
|
-
from httpware.errors import NetworkError, RetryBudgetExhaustedError, StatusError, TimeoutError # noqa: A004
|
|
23
|
-
from httpware.middleware import AsyncNext, Next
|
|
24
|
-
from httpware.middleware.resilience._backoff import full_jitter_delay
|
|
25
|
-
from httpware.middleware.resilience.budget import RetryBudget
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
DEFAULT_RETRY_STATUS_CODES = frozenset(
|
|
29
|
-
{
|
|
30
|
-
int(HTTPStatus.REQUEST_TIMEOUT),
|
|
31
|
-
int(HTTPStatus.TOO_MANY_REQUESTS),
|
|
32
|
-
int(HTTPStatus.BAD_GATEWAY),
|
|
33
|
-
int(HTTPStatus.SERVICE_UNAVAILABLE),
|
|
34
|
-
int(HTTPStatus.GATEWAY_TIMEOUT),
|
|
35
|
-
}
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
39
|
-
{
|
|
40
|
-
"GET",
|
|
41
|
-
"HEAD",
|
|
42
|
-
"OPTIONS",
|
|
43
|
-
"PUT",
|
|
44
|
-
"DELETE",
|
|
45
|
-
}
|
|
46
|
-
)
|
|
47
|
-
|
|
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
|
-
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE = (
|
|
51
|
-
"httpware: Retry-After ({retry_after}s) exceeded max_delay ({max_delay}s); giving up"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
_LOGGER = logging.getLogger("httpware.retry")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _parse_retry_after(value: str) -> float | None:
|
|
58
|
-
"""Parse a Retry-After header value. Returns None on malformed input."""
|
|
59
|
-
try:
|
|
60
|
-
return max(0.0, float(int(value))) # clamp: negative integers are malformed servers
|
|
61
|
-
except (ValueError, OverflowError):
|
|
62
|
-
pass
|
|
63
|
-
try:
|
|
64
|
-
parsed = email.utils.parsedate_to_datetime(value)
|
|
65
|
-
except (TypeError, ValueError):
|
|
66
|
-
return None
|
|
67
|
-
if parsed is None: # pragma: no cover — parsedate_to_datetime raises rather than returning None in CPython 3.11+
|
|
68
|
-
return None
|
|
69
|
-
now = datetime.datetime.now(datetime.UTC)
|
|
70
|
-
delta = (parsed - now).total_seconds()
|
|
71
|
-
return max(0.0, delta)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class AsyncRetry:
|
|
75
|
-
"""Async retry middleware. See module docstring for default policy."""
|
|
76
|
-
|
|
77
|
-
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
78
|
-
self,
|
|
79
|
-
*,
|
|
80
|
-
max_attempts: int = 3,
|
|
81
|
-
base_delay: float = 0.1,
|
|
82
|
-
max_delay: float = 5.0,
|
|
83
|
-
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
84
|
-
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
85
|
-
respect_retry_after: bool = True,
|
|
86
|
-
budget: RetryBudget | None = None,
|
|
87
|
-
_sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
88
|
-
) -> None:
|
|
89
|
-
if max_attempts < 1:
|
|
90
|
-
raise ValueError(_MAX_ATTEMPTS_INVALID)
|
|
91
|
-
self.max_attempts = max_attempts
|
|
92
|
-
self.base_delay = base_delay
|
|
93
|
-
self.max_delay = max_delay
|
|
94
|
-
self.retry_status_codes = retry_status_codes
|
|
95
|
-
self.retry_methods = retry_methods
|
|
96
|
-
self.respect_retry_after = respect_retry_after
|
|
97
|
-
self.budget = budget if budget is not None else RetryBudget()
|
|
98
|
-
self._sleep = _sleep
|
|
99
|
-
|
|
100
|
-
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — complexity budget: 3 error clauses + idempotency gate + streaming-body refusal + budget gate + Retry-After branch + backoff
|
|
101
|
-
"""Process a request through the retry loop. See module docstring."""
|
|
102
|
-
method_eligible = request.method.upper() in self.retry_methods
|
|
103
|
-
last_exc: BaseException | None = None
|
|
104
|
-
last_response: httpx2.Response | None = None
|
|
105
|
-
|
|
106
|
-
self.budget.deposit()
|
|
107
|
-
for attempt in range(self.max_attempts):
|
|
108
|
-
is_last = attempt + 1 >= self.max_attempts
|
|
109
|
-
try:
|
|
110
|
-
return await next(request)
|
|
111
|
-
except StatusError as exc:
|
|
112
|
-
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
113
|
-
if not method_eligible or not retryable_status:
|
|
114
|
-
raise
|
|
115
|
-
last_exc = exc
|
|
116
|
-
last_response = exc.response
|
|
117
|
-
except (NetworkError, TimeoutError) as exc:
|
|
118
|
-
if not method_eligible:
|
|
119
|
-
raise
|
|
120
|
-
last_exc = exc
|
|
121
|
-
last_response = None
|
|
122
|
-
|
|
123
|
-
# ---- retryable failure path
|
|
124
|
-
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
125
|
-
if last_exc is None: # pragma: no cover — invariant from except branch
|
|
126
|
-
msg = "AsyncRetry: streaming-body refusal reached with no last_exc"
|
|
127
|
-
raise AssertionError(msg)
|
|
128
|
-
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
129
|
-
_emit_event(
|
|
130
|
-
_LOGGER,
|
|
131
|
-
"retry.streaming_refused",
|
|
132
|
-
level=logging.WARNING,
|
|
133
|
-
message="retry refused — request body is a stream that cannot replay",
|
|
134
|
-
attributes={
|
|
135
|
-
"method": request.method,
|
|
136
|
-
"url": str(request.url),
|
|
137
|
-
"last_exception_type": type(last_exc).__qualname__,
|
|
138
|
-
},
|
|
139
|
-
)
|
|
140
|
-
raise last_exc
|
|
141
|
-
|
|
142
|
-
if is_last:
|
|
143
|
-
if last_exc is None: # pragma: no cover — structural invariant from except branch
|
|
144
|
-
msg = "AsyncRetry: last_exc unset on final attempt — unreachable"
|
|
145
|
-
raise AssertionError(msg)
|
|
146
|
-
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
147
|
-
_emit_event(
|
|
148
|
-
_LOGGER,
|
|
149
|
-
"retry.giving_up",
|
|
150
|
-
level=logging.WARNING,
|
|
151
|
-
message=f"retry gave up after {attempt + 1} attempts",
|
|
152
|
-
attributes={
|
|
153
|
-
"attempts": attempt + 1,
|
|
154
|
-
"method": request.method,
|
|
155
|
-
"url": str(request.url),
|
|
156
|
-
"last_status": last_response.status_code if last_response is not None else None,
|
|
157
|
-
"last_exception_type": type(last_exc).__qualname__,
|
|
158
|
-
},
|
|
159
|
-
)
|
|
160
|
-
raise last_exc
|
|
161
|
-
|
|
162
|
-
retry_after: float | None = None
|
|
163
|
-
if self.respect_retry_after and last_response is not None:
|
|
164
|
-
header = last_response.headers.get("Retry-After")
|
|
165
|
-
if header is not None:
|
|
166
|
-
retry_after = _parse_retry_after(header)
|
|
167
|
-
|
|
168
|
-
if retry_after is not None and retry_after > self.max_delay:
|
|
169
|
-
if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
|
|
170
|
-
msg = "AsyncRetry: retry_after path reached with no last_exc"
|
|
171
|
-
raise AssertionError(msg)
|
|
172
|
-
last_exc.add_note(
|
|
173
|
-
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
|
|
174
|
-
retry_after=retry_after,
|
|
175
|
-
max_delay=self.max_delay,
|
|
176
|
-
),
|
|
177
|
-
)
|
|
178
|
-
raise last_exc
|
|
179
|
-
|
|
180
|
-
if not self.budget.try_withdraw():
|
|
181
|
-
_emit_event(
|
|
182
|
-
_LOGGER,
|
|
183
|
-
"retry.budget_refused",
|
|
184
|
-
level=logging.WARNING,
|
|
185
|
-
message=f"retry budget refused after {attempt + 1} attempts",
|
|
186
|
-
attributes={
|
|
187
|
-
"attempts": attempt + 1,
|
|
188
|
-
"method": request.method,
|
|
189
|
-
"url": str(request.url),
|
|
190
|
-
"last_status": last_response.status_code if last_response is not None else None,
|
|
191
|
-
},
|
|
192
|
-
)
|
|
193
|
-
raise RetryBudgetExhaustedError(
|
|
194
|
-
last_response=last_response,
|
|
195
|
-
last_exception=last_exc,
|
|
196
|
-
attempts=attempt + 1,
|
|
197
|
-
) from last_exc
|
|
198
|
-
|
|
199
|
-
if retry_after is not None:
|
|
200
|
-
delay = retry_after
|
|
201
|
-
else:
|
|
202
|
-
delay = full_jitter_delay(
|
|
203
|
-
attempt,
|
|
204
|
-
base_delay=self.base_delay,
|
|
205
|
-
max_delay=self.max_delay,
|
|
206
|
-
)
|
|
207
|
-
await self._sleep(delay)
|
|
208
|
-
|
|
209
|
-
msg = "unreachable" # pragma: no cover
|
|
210
|
-
raise AssertionError(msg) # pragma: no cover
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
class Retry:
|
|
214
|
-
"""Sync retry middleware. Mirror of AsyncRetry; uses time.sleep instead of asyncio.sleep."""
|
|
215
|
-
|
|
216
|
-
def __init__( # noqa: PLR0913 — retry policy has many orthogonal knobs; a dataclass would be worse
|
|
217
|
-
self,
|
|
218
|
-
*,
|
|
219
|
-
max_attempts: int = 3,
|
|
220
|
-
base_delay: float = 0.1,
|
|
221
|
-
max_delay: float = 5.0,
|
|
222
|
-
retry_status_codes: frozenset[int] = DEFAULT_RETRY_STATUS_CODES,
|
|
223
|
-
retry_methods: frozenset[str] = DEFAULT_IDEMPOTENT_METHODS,
|
|
224
|
-
respect_retry_after: bool = True,
|
|
225
|
-
budget: RetryBudget | None = None,
|
|
226
|
-
_sleep: Callable[[float], None] = time.sleep,
|
|
227
|
-
) -> None:
|
|
228
|
-
if max_attempts < 1:
|
|
229
|
-
raise ValueError(_MAX_ATTEMPTS_INVALID)
|
|
230
|
-
self.max_attempts = max_attempts
|
|
231
|
-
self.base_delay = base_delay
|
|
232
|
-
self.max_delay = max_delay
|
|
233
|
-
self.retry_status_codes = retry_status_codes
|
|
234
|
-
self.retry_methods = retry_methods
|
|
235
|
-
self.respect_retry_after = respect_retry_after
|
|
236
|
-
self.budget = budget if budget is not None else RetryBudget()
|
|
237
|
-
self._sleep = _sleep
|
|
238
|
-
|
|
239
|
-
def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # noqa: A002, C901, PLR0912, PLR0915 — same complexity rationale as AsyncRetry
|
|
240
|
-
"""Process a request through the sync retry loop. See AsyncRetry for full contract."""
|
|
241
|
-
method_eligible = request.method.upper() in self.retry_methods
|
|
242
|
-
last_exc: BaseException | None = None
|
|
243
|
-
last_response: httpx2.Response | None = None
|
|
244
|
-
|
|
245
|
-
self.budget.deposit()
|
|
246
|
-
for attempt in range(self.max_attempts):
|
|
247
|
-
is_last = attempt + 1 >= self.max_attempts
|
|
248
|
-
try:
|
|
249
|
-
return next(request)
|
|
250
|
-
except StatusError as exc:
|
|
251
|
-
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
252
|
-
if not method_eligible or not retryable_status:
|
|
253
|
-
raise
|
|
254
|
-
last_exc = exc
|
|
255
|
-
last_response = exc.response
|
|
256
|
-
except (NetworkError, TimeoutError) as exc:
|
|
257
|
-
if not method_eligible:
|
|
258
|
-
raise
|
|
259
|
-
last_exc = exc
|
|
260
|
-
last_response = None
|
|
261
|
-
|
|
262
|
-
# ---- retryable failure path
|
|
263
|
-
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
264
|
-
if last_exc is None: # pragma: no cover — invariant from except branch
|
|
265
|
-
msg = "Retry: streaming-body refusal reached with no last_exc"
|
|
266
|
-
raise AssertionError(msg)
|
|
267
|
-
last_exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
268
|
-
_emit_event(
|
|
269
|
-
_LOGGER,
|
|
270
|
-
"retry.streaming_refused",
|
|
271
|
-
level=logging.WARNING,
|
|
272
|
-
message="retry refused — request body is a stream that cannot replay",
|
|
273
|
-
attributes={
|
|
274
|
-
"method": request.method,
|
|
275
|
-
"url": str(request.url),
|
|
276
|
-
"last_exception_type": type(last_exc).__qualname__,
|
|
277
|
-
},
|
|
278
|
-
)
|
|
279
|
-
raise last_exc
|
|
280
|
-
|
|
281
|
-
if is_last:
|
|
282
|
-
if last_exc is None: # pragma: no cover — structural invariant from except branch
|
|
283
|
-
msg = "Retry: last_exc unset on final attempt — unreachable"
|
|
284
|
-
raise AssertionError(msg)
|
|
285
|
-
last_exc.add_note(f"httpware: gave up after {attempt + 1} attempts")
|
|
286
|
-
_emit_event(
|
|
287
|
-
_LOGGER,
|
|
288
|
-
"retry.giving_up",
|
|
289
|
-
level=logging.WARNING,
|
|
290
|
-
message=f"retry gave up after {attempt + 1} attempts",
|
|
291
|
-
attributes={
|
|
292
|
-
"attempts": attempt + 1,
|
|
293
|
-
"method": request.method,
|
|
294
|
-
"url": str(request.url),
|
|
295
|
-
"last_status": last_response.status_code if last_response is not None else None,
|
|
296
|
-
"last_exception_type": type(last_exc).__qualname__,
|
|
297
|
-
},
|
|
298
|
-
)
|
|
299
|
-
raise last_exc
|
|
300
|
-
|
|
301
|
-
retry_after: float | None = None
|
|
302
|
-
if self.respect_retry_after and last_response is not None:
|
|
303
|
-
header = last_response.headers.get("Retry-After")
|
|
304
|
-
if header is not None:
|
|
305
|
-
retry_after = _parse_retry_after(header)
|
|
306
|
-
|
|
307
|
-
if retry_after is not None and retry_after > self.max_delay:
|
|
308
|
-
if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
|
|
309
|
-
msg = "Retry: retry_after path reached with no last_exc"
|
|
310
|
-
raise AssertionError(msg)
|
|
311
|
-
last_exc.add_note(
|
|
312
|
-
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
|
|
313
|
-
retry_after=retry_after,
|
|
314
|
-
max_delay=self.max_delay,
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
raise last_exc
|
|
318
|
-
|
|
319
|
-
if not self.budget.try_withdraw():
|
|
320
|
-
_emit_event(
|
|
321
|
-
_LOGGER,
|
|
322
|
-
"retry.budget_refused",
|
|
323
|
-
level=logging.WARNING,
|
|
324
|
-
message=f"retry budget refused after {attempt + 1} attempts",
|
|
325
|
-
attributes={
|
|
326
|
-
"attempts": attempt + 1,
|
|
327
|
-
"method": request.method,
|
|
328
|
-
"url": str(request.url),
|
|
329
|
-
"last_status": last_response.status_code if last_response is not None else None,
|
|
330
|
-
},
|
|
331
|
-
)
|
|
332
|
-
raise RetryBudgetExhaustedError(
|
|
333
|
-
last_response=last_response,
|
|
334
|
-
last_exception=last_exc,
|
|
335
|
-
attempts=attempt + 1,
|
|
336
|
-
) from last_exc
|
|
337
|
-
|
|
338
|
-
if retry_after is not None:
|
|
339
|
-
delay = retry_after
|
|
340
|
-
else:
|
|
341
|
-
delay = full_jitter_delay(
|
|
342
|
-
attempt,
|
|
343
|
-
base_delay=self.base_delay,
|
|
344
|
-
max_delay=self.max_delay,
|
|
345
|
-
)
|
|
346
|
-
self._sleep(delay)
|
|
347
|
-
|
|
348
|
-
msg = "unreachable" # pragma: no cover
|
|
349
|
-
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
|
|
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
|