httpware 0.8.1__tar.gz → 0.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpware-0.8.1 → httpware-0.8.3}/PKG-INFO +6 -4
- {httpware-0.8.1 → httpware-0.8.3}/README.md +5 -3
- {httpware-0.8.1 → httpware-0.8.3}/pyproject.toml +1 -1
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/client.py +46 -2
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/__init__.py +1 -1
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/budget.py +2 -1
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/bulkhead.py +37 -1
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/retry.py +29 -12
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/_internal/status.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/msgspec.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/decoders/pydantic.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/errors.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.8.1 → httpware-0.8.3}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
4
4
|
Summary: Resilience-first async HTTP client framework for Python
|
|
5
5
|
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -35,9 +35,9 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
[](https://pypi.org/project/httpware/)
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
37
|
|
|
38
|
-
**
|
|
38
|
+
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
39
39
|
|
|
40
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
40
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
41
41
|
|
|
42
42
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
43
43
|
|
|
@@ -101,6 +101,8 @@ async def main() -> None:
|
|
|
101
101
|
|
|
102
102
|
Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
|
|
103
103
|
|
|
104
|
+
The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
|
|
105
|
+
|
|
104
106
|
```python
|
|
105
107
|
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
|
|
106
108
|
|
|
@@ -143,7 +145,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
143
145
|
|
|
144
146
|
## Observability
|
|
145
147
|
|
|
146
|
-
`AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
148
|
+
`AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
|
|
147
149
|
|
|
148
150
|
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
149
151
|
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
[](https://pypi.org/project/httpware/)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
9
9
|
|
|
10
|
-
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
10
|
+
`httpware` is a thin opinionated wrapper around `httpx2`. It re-exports `httpx2.Request`/`httpx2.Response`, adds a middleware chain composed at client construction, supports opt-in typed response decoding (pydantic and msgspec are both extras), and raises a status-keyed exception tree automatically on 4xx/5xx. It also ships a small resilience suite — `AsyncRetry`/`Retry` middleware with a Finagle-style `RetryBudget`, plus an `AsyncBulkhead`/`Bulkhead` concurrency limiter — under `httpware.middleware.resilience`.
|
|
11
11
|
|
|
12
12
|
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
13
13
|
|
|
@@ -71,6 +71,8 @@ async def main() -> None:
|
|
|
71
71
|
|
|
72
72
|
Compose resilience middleware at construction; `AsyncBulkhead` goes outside `AsyncRetry` so one slot covers all retry attempts.
|
|
73
73
|
|
|
74
|
+
The sync `Client` accepts identical `middleware=[...]`; swap `AsyncClient` → `Client` and `AsyncRetry` → `Retry` for the sync version.
|
|
75
|
+
|
|
74
76
|
```python
|
|
75
77
|
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
|
|
76
78
|
|
|
@@ -113,7 +115,7 @@ All 4xx/5xx responses raise typed exceptions automatically: `NotFoundError`, `Se
|
|
|
113
115
|
|
|
114
116
|
## Observability
|
|
115
117
|
|
|
116
|
-
`AsyncRetry` and `AsyncBulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed).
|
|
118
|
+
`AsyncRetry`/`Retry` and `AsyncBulkhead`/`Bulkhead` emit operational events via two channels — stdlib `logging` records (always on) and OpenTelemetry span events (when `opentelemetry-api` is installed). Event names and payloads are identical across sync and async; dashboards built against one class apply unchanged to the other.
|
|
117
119
|
|
|
118
120
|
Logger names (`httpware.retry`, `httpware.bulkhead`) and event names (`retry.giving_up`, `retry.budget_refused`, `retry.streaming_refused`, `bulkhead.rejected`) are the stable public contract.
|
|
119
121
|
|
|
@@ -132,7 +132,7 @@ class AsyncClient:
|
|
|
132
132
|
async with _httpx2_exception_mapper():
|
|
133
133
|
response = await self._httpx2_client.send(request)
|
|
134
134
|
except RuntimeError as exc:
|
|
135
|
-
if
|
|
135
|
+
if self._httpx2_client.is_closed:
|
|
136
136
|
raise TransportError(str(exc)) from exc
|
|
137
137
|
raise
|
|
138
138
|
_raise_on_status_error(response)
|
|
@@ -159,6 +159,28 @@ class AsyncClient:
|
|
|
159
159
|
except Exception as exc:
|
|
160
160
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
161
161
|
|
|
162
|
+
async def send_with_response(
|
|
163
|
+
self,
|
|
164
|
+
request: httpx2.Request,
|
|
165
|
+
*,
|
|
166
|
+
response_model: type[T],
|
|
167
|
+
) -> tuple[httpx2.Response, T]:
|
|
168
|
+
"""Send `request` through the middleware chain; return (response, decoded).
|
|
169
|
+
|
|
170
|
+
Use this when you need response metadata (headers, status, request URL)
|
|
171
|
+
AND a typed body — most commonly for Link-header pagination. For the
|
|
172
|
+
body-only case, prefer ``send(request, response_model=...)``.
|
|
173
|
+
|
|
174
|
+
Not for streaming responses — decodes ``response.content``, which
|
|
175
|
+
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
176
|
+
"""
|
|
177
|
+
response = await self._dispatch(request)
|
|
178
|
+
try:
|
|
179
|
+
decoded = self._decoder.decode(response.content, response_model)
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
182
|
+
return response, decoded
|
|
183
|
+
|
|
162
184
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
163
185
|
"""Delegate request construction to the wrapped httpx2.AsyncClient."""
|
|
164
186
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
@@ -828,7 +850,7 @@ class Client:
|
|
|
828
850
|
with _httpx2_exception_mapper_sync():
|
|
829
851
|
response = self._httpx2_client.send(request)
|
|
830
852
|
except RuntimeError as exc:
|
|
831
|
-
if
|
|
853
|
+
if self._httpx2_client.is_closed:
|
|
832
854
|
raise TransportError(str(exc)) from exc
|
|
833
855
|
raise
|
|
834
856
|
_raise_on_status_error(response)
|
|
@@ -879,6 +901,28 @@ class Client:
|
|
|
879
901
|
except Exception as exc:
|
|
880
902
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
881
903
|
|
|
904
|
+
def send_with_response(
|
|
905
|
+
self,
|
|
906
|
+
request: httpx2.Request,
|
|
907
|
+
*,
|
|
908
|
+
response_model: type[T],
|
|
909
|
+
) -> tuple[httpx2.Response, T]:
|
|
910
|
+
"""Send `request` through the middleware chain; return (response, decoded).
|
|
911
|
+
|
|
912
|
+
Use this when you need response metadata (headers, status, request URL)
|
|
913
|
+
AND a typed body — most commonly for Link-header pagination. For the
|
|
914
|
+
body-only case, prefer ``send(request, response_model=...)``.
|
|
915
|
+
|
|
916
|
+
Not for streaming responses — decodes ``response.content``, which
|
|
917
|
+
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
918
|
+
"""
|
|
919
|
+
response = self._dispatch(request)
|
|
920
|
+
try:
|
|
921
|
+
decoded = self._decoder.decode(response.content, response_model)
|
|
922
|
+
except Exception as exc:
|
|
923
|
+
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
924
|
+
return response, decoded
|
|
925
|
+
|
|
882
926
|
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
|
|
883
927
|
"""Delegate request construction to the wrapped httpx2.Client."""
|
|
884
928
|
return self._httpx2_client.build_request(method, url, **kwargs)
|
|
@@ -8,6 +8,7 @@ coroutines on one event loop, and across (sync Client, AsyncClient) pairs
|
|
|
8
8
|
in the same process.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import math
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
13
14
|
from collections import deque
|
|
@@ -64,7 +65,7 @@ class RetryBudget:
|
|
|
64
65
|
with self._lock:
|
|
65
66
|
self._purge(now)
|
|
66
67
|
floor = int(self._min_retries_per_sec * self._ttl)
|
|
67
|
-
ceiling =
|
|
68
|
+
ceiling = math.ceil(len(self._deposits) * self._percent_can_retry) + floor
|
|
68
69
|
if len(self._withdrawn) >= ceiling:
|
|
69
70
|
return False
|
|
70
71
|
self._withdrawn.append(now)
|
|
@@ -9,6 +9,13 @@ all release deterministically.
|
|
|
9
9
|
|
|
10
10
|
AsyncBulkhead is the sharable unit — pass the same instance to multiple
|
|
11
11
|
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
|
|
12
|
+
|
|
13
|
+
AsyncBulkhead is single-event-loop: the underlying asyncio.Semaphore binds
|
|
14
|
+
to whichever loop first awaits it, and cross-loop wake-ups are not thread
|
|
15
|
+
safe. A single instance acquired from a second event loop (e.g. another
|
|
16
|
+
thread running asyncio.run) raises RuntimeError on entry rather than
|
|
17
|
+
deadlocking silently. To cap a sync+async or cross-thread workload, use
|
|
18
|
+
a Bulkhead and an AsyncBulkhead with matching max_concurrent.
|
|
12
19
|
"""
|
|
13
20
|
|
|
14
21
|
import asyncio
|
|
@@ -24,6 +31,11 @@ from httpware.middleware import AsyncNext, Next
|
|
|
24
31
|
|
|
25
32
|
_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
|
|
26
33
|
_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
|
|
34
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG = (
|
|
35
|
+
"AsyncBulkhead is bound to a single event loop. First seen on {first!r}; "
|
|
36
|
+
"current request is on {current!r}. Use one AsyncBulkhead per loop; "
|
|
37
|
+
"cross-thread sharing requires the sync Bulkhead primitive."
|
|
38
|
+
)
|
|
27
39
|
|
|
28
40
|
_LOGGER = logging.getLogger("httpware.bulkhead")
|
|
29
41
|
|
|
@@ -42,7 +54,8 @@ class AsyncBulkhead:
|
|
|
42
54
|
Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
|
|
43
55
|
``>= 0`` (or ``None``).
|
|
44
56
|
|
|
45
|
-
See the module docstring for the algorithm
|
|
57
|
+
See the module docstring for the algorithm, middleware-ordering guidance,
|
|
58
|
+
and the single-event-loop constraint.
|
|
46
59
|
|
|
47
60
|
"""
|
|
48
61
|
|
|
@@ -59,9 +72,32 @@ class AsyncBulkhead:
|
|
|
59
72
|
self._max_concurrent = max_concurrent
|
|
60
73
|
self._acquire_timeout = acquire_timeout
|
|
61
74
|
self._sem = asyncio.Semaphore(max_concurrent)
|
|
75
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
76
|
+
self._loop_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def _check_loop(self) -> None:
|
|
79
|
+
current = asyncio.get_running_loop()
|
|
80
|
+
cached = self._loop
|
|
81
|
+
if cached is current:
|
|
82
|
+
return
|
|
83
|
+
if cached is not None:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=cached, current=current),
|
|
86
|
+
)
|
|
87
|
+
with self._loop_lock:
|
|
88
|
+
if self._loop is None:
|
|
89
|
+
self._loop = current
|
|
90
|
+
# pragma below: inner double-check-with-lock race arm; only
|
|
91
|
+
# reachable when two threads simultaneously pass the outer
|
|
92
|
+
# cached-loop check, which single-threaded tests can't trigger.
|
|
93
|
+
elif self._loop is not current: # pragma: no cover
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
_ASYNCBULKHEAD_CROSS_LOOP_MSG.format(first=self._loop, current=current),
|
|
96
|
+
)
|
|
62
97
|
|
|
63
98
|
async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Response: # noqa: A002
|
|
64
99
|
"""Acquire a slot (bounded by acquire_timeout), invoke next, release."""
|
|
100
|
+
self._check_loop()
|
|
65
101
|
try:
|
|
66
102
|
if self._acquire_timeout is None:
|
|
67
103
|
await self._sem.acquire()
|
|
@@ -47,6 +47,9 @@ DEFAULT_IDEMPOTENT_METHODS = frozenset(
|
|
|
47
47
|
|
|
48
48
|
_MAX_ATTEMPTS_INVALID = "max_attempts must be >= 1"
|
|
49
49
|
_STREAMING_BODY_REFUSAL_NOTE = "httpware: not retrying — request body is a stream that cannot replay across attempts"
|
|
50
|
+
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE = (
|
|
51
|
+
"httpware: Retry-After ({retry_after}s) exceeded max_delay ({max_delay}s); giving up"
|
|
52
|
+
)
|
|
50
53
|
|
|
51
54
|
_LOGGER = logging.getLogger("httpware.retry")
|
|
52
55
|
|
|
@@ -100,23 +103,19 @@ class AsyncRetry:
|
|
|
100
103
|
last_exc: BaseException | None = None
|
|
101
104
|
last_response: httpx2.Response | None = None
|
|
102
105
|
|
|
106
|
+
self.budget.deposit()
|
|
103
107
|
for attempt in range(self.max_attempts):
|
|
104
108
|
is_last = attempt + 1 >= self.max_attempts
|
|
105
|
-
self.budget.deposit()
|
|
106
109
|
try:
|
|
107
110
|
return await next(request)
|
|
108
111
|
except StatusError as exc:
|
|
109
112
|
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
110
113
|
if not method_eligible or not retryable_status:
|
|
111
|
-
if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
|
|
112
|
-
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
113
114
|
raise
|
|
114
115
|
last_exc = exc
|
|
115
116
|
last_response = exc.response
|
|
116
117
|
except (NetworkError, TimeoutError) as exc:
|
|
117
118
|
if not method_eligible:
|
|
118
|
-
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
119
|
-
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
120
119
|
raise
|
|
121
120
|
last_exc = exc
|
|
122
121
|
last_response = None
|
|
@@ -185,8 +184,19 @@ class AsyncRetry:
|
|
|
185
184
|
if header is not None:
|
|
186
185
|
retry_after = _parse_retry_after(header)
|
|
187
186
|
|
|
187
|
+
if retry_after is not None and retry_after > self.max_delay:
|
|
188
|
+
if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
|
|
189
|
+
msg = "AsyncRetry: retry_after path reached with no last_exc"
|
|
190
|
+
raise AssertionError(msg)
|
|
191
|
+
last_exc.add_note(
|
|
192
|
+
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
|
|
193
|
+
retry_after=retry_after,
|
|
194
|
+
max_delay=self.max_delay,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
raise last_exc
|
|
188
198
|
if retry_after is not None:
|
|
189
|
-
delay =
|
|
199
|
+
delay = retry_after
|
|
190
200
|
else:
|
|
191
201
|
delay = full_jitter_delay(
|
|
192
202
|
attempt,
|
|
@@ -231,23 +241,19 @@ class Retry:
|
|
|
231
241
|
last_exc: BaseException | None = None
|
|
232
242
|
last_response: httpx2.Response | None = None
|
|
233
243
|
|
|
244
|
+
self.budget.deposit()
|
|
234
245
|
for attempt in range(self.max_attempts):
|
|
235
246
|
is_last = attempt + 1 >= self.max_attempts
|
|
236
|
-
self.budget.deposit()
|
|
237
247
|
try:
|
|
238
248
|
return next(request)
|
|
239
249
|
except StatusError as exc:
|
|
240
250
|
retryable_status = exc.response.status_code in self.retry_status_codes
|
|
241
251
|
if not method_eligible or not retryable_status:
|
|
242
|
-
if retryable_status and request.extensions.get(STREAMING_BODY_MARKER):
|
|
243
|
-
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
244
252
|
raise
|
|
245
253
|
last_exc = exc
|
|
246
254
|
last_response = exc.response
|
|
247
255
|
except (NetworkError, TimeoutError) as exc:
|
|
248
256
|
if not method_eligible:
|
|
249
|
-
if request.extensions.get(STREAMING_BODY_MARKER):
|
|
250
|
-
exc.add_note(_STREAMING_BODY_REFUSAL_NOTE)
|
|
251
257
|
raise
|
|
252
258
|
last_exc = exc
|
|
253
259
|
last_response = None
|
|
@@ -316,8 +322,19 @@ class Retry:
|
|
|
316
322
|
if header is not None:
|
|
317
323
|
retry_after = _parse_retry_after(header)
|
|
318
324
|
|
|
325
|
+
if retry_after is not None and retry_after > self.max_delay:
|
|
326
|
+
if last_exc is None: # pragma: no cover — retry_after requires last_response which requires last_exc
|
|
327
|
+
msg = "Retry: retry_after path reached with no last_exc"
|
|
328
|
+
raise AssertionError(msg)
|
|
329
|
+
last_exc.add_note(
|
|
330
|
+
_RETRY_AFTER_EXCEEDS_MAX_DELAY_NOTE.format(
|
|
331
|
+
retry_after=retry_after,
|
|
332
|
+
max_delay=self.max_delay,
|
|
333
|
+
),
|
|
334
|
+
)
|
|
335
|
+
raise last_exc
|
|
319
336
|
if retry_after is not None:
|
|
320
|
-
delay =
|
|
337
|
+
delay = retry_after
|
|
321
338
|
else:
|
|
322
339
|
delay = full_jitter_delay(
|
|
323
340
|
attempt,
|
|
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
|