retryhttp2 0.1.0__py3-none-any.whl
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.
- retryhttp/__init__.py +20 -0
- retryhttp/backoff.py +39 -0
- retryhttp/httpx_client.py +234 -0
- retryhttp/requests_client.py +143 -0
- retryhttp/retry.py +82 -0
- retryhttp2-0.1.0.dist-info/METADATA +172 -0
- retryhttp2-0.1.0.dist-info/RECORD +9 -0
- retryhttp2-0.1.0.dist-info/WHEEL +4 -0
- retryhttp2-0.1.0.dist-info/licenses/LICENSE +21 -0
retryhttp/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""retryhttp — zero-config HTTP retries with backoff and OAuth2 token refresh."""
|
|
2
|
+
|
|
3
|
+
from retryhttp.retry import RetryConfig, RetryExhausted
|
|
4
|
+
from retryhttp.backoff import backoff_delay
|
|
5
|
+
|
|
6
|
+
__all__ = ["RetryConfig", "RetryExhausted", "backoff_delay"]
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from retryhttp.httpx_client import RetryClient, AsyncRetryClient
|
|
10
|
+
|
|
11
|
+
__all__ += ["RetryClient", "AsyncRetryClient"]
|
|
12
|
+
except ImportError:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from retryhttp.requests_client import RetrySession
|
|
17
|
+
|
|
18
|
+
__all__ += ["RetrySession"]
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
retryhttp/backoff.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Backoff delay calculation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def backoff_delay(
|
|
9
|
+
attempt: int,
|
|
10
|
+
*,
|
|
11
|
+
base: float = 1.0,
|
|
12
|
+
maximum: float = 60.0,
|
|
13
|
+
jitter: bool = True,
|
|
14
|
+
) -> float:
|
|
15
|
+
"""Compute an exponential backoff delay with optional full jitter.
|
|
16
|
+
|
|
17
|
+
Uses the "Full Jitter" strategy from AWS architecture blog:
|
|
18
|
+
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
attempt: Zero-indexed attempt number (0 = first retry → shortest delay).
|
|
22
|
+
base: Backoff base in seconds.
|
|
23
|
+
maximum: Hard ceiling on the computed delay.
|
|
24
|
+
jitter: When True, randomise within [0, computed_cap].
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Seconds to sleep before the next attempt.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
for i in range(3):
|
|
32
|
+
time.sleep(backoff_delay(i, base=0.5, maximum=30))
|
|
33
|
+
"""
|
|
34
|
+
if base <= 0:
|
|
35
|
+
raise ValueError("base must be positive")
|
|
36
|
+
cap = min(maximum, base * (2**attempt))
|
|
37
|
+
if jitter:
|
|
38
|
+
return random.uniform(0, cap)
|
|
39
|
+
return cap
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""httpx adapters: RetryClient (sync) and AsyncRetryClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from retryhttp.backoff import backoff_delay
|
|
14
|
+
from retryhttp.retry import RetryConfig, RetryExhausted
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Types for the token-refresh callable.
|
|
19
|
+
# Sync: () -> str | Async: async () -> str
|
|
20
|
+
TokenRefresher = Callable[[], str]
|
|
21
|
+
AsyncTokenRefresher = Callable[[], Any] # must be a coroutine function
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RetryClient(httpx.Client):
|
|
25
|
+
"""An httpx.Client that retries failed requests with exponential backoff.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Retry policy. Defaults to RetryConfig() (3 attempts, sane backoff).
|
|
29
|
+
token_refresher: Optional callable ``() -> str`` that returns a fresh
|
|
30
|
+
bearer token. Called automatically on 401 responses. The new token
|
|
31
|
+
is injected into ``self.headers["Authorization"]`` before the retry.
|
|
32
|
+
**kwargs: Forwarded verbatim to httpx.Client.
|
|
33
|
+
|
|
34
|
+
Example::
|
|
35
|
+
|
|
36
|
+
def refresh() -> str:
|
|
37
|
+
return fetch_new_token()
|
|
38
|
+
|
|
39
|
+
with RetryClient(token_refresher=refresh) as client:
|
|
40
|
+
resp = client.get("https://api.example.com/data")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
config: RetryConfig | None = None,
|
|
47
|
+
token_refresher: TokenRefresher | None = None,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self.retry_config = config or RetryConfig()
|
|
52
|
+
self._token_refresher = token_refresher
|
|
53
|
+
|
|
54
|
+
def _maybe_refresh_token(self) -> None:
|
|
55
|
+
if self._token_refresher is not None:
|
|
56
|
+
new_token = self._token_refresher()
|
|
57
|
+
self.headers["Authorization"] = f"Bearer {new_token}"
|
|
58
|
+
logger.debug("retryhttp: token refreshed")
|
|
59
|
+
|
|
60
|
+
def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: # type: ignore[override]
|
|
61
|
+
cfg = self.retry_config
|
|
62
|
+
last_exc: Exception | None = None
|
|
63
|
+
last_response: httpx.Response | None = None
|
|
64
|
+
|
|
65
|
+
for attempt in range(cfg.max_attempts):
|
|
66
|
+
try:
|
|
67
|
+
response = super().send(request, **kwargs)
|
|
68
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
69
|
+
if not cfg.retry_on_network_error or attempt == cfg.max_attempts - 1:
|
|
70
|
+
raise
|
|
71
|
+
last_exc = exc
|
|
72
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
73
|
+
logger.warning(
|
|
74
|
+
"retryhttp: network error on attempt %d/%d (%s), retrying in %.2fs",
|
|
75
|
+
attempt + 1,
|
|
76
|
+
cfg.max_attempts,
|
|
77
|
+
exc,
|
|
78
|
+
delay,
|
|
79
|
+
)
|
|
80
|
+
time.sleep(delay)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
last_response = response
|
|
84
|
+
last_exc = None
|
|
85
|
+
|
|
86
|
+
# Token refresh on 401.
|
|
87
|
+
if cfg.token_refresh_status and response.status_code == cfg.token_refresh_status:
|
|
88
|
+
if attempt < cfg.max_attempts - 1:
|
|
89
|
+
logger.info("retryhttp: 401 received, refreshing token (attempt %d)", attempt + 1)
|
|
90
|
+
self._maybe_refresh_token()
|
|
91
|
+
# Re-build the request so the new Authorization header is sent.
|
|
92
|
+
request = self.build_request(
|
|
93
|
+
method=request.method,
|
|
94
|
+
url=request.url,
|
|
95
|
+
content=request.content,
|
|
96
|
+
headers=dict(request.headers),
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if not cfg.should_retry_status(response.status_code):
|
|
101
|
+
return response
|
|
102
|
+
|
|
103
|
+
if attempt == cfg.max_attempts - 1:
|
|
104
|
+
if cfg.raise_on_exhaust:
|
|
105
|
+
raise RetryExhausted(
|
|
106
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
107
|
+
attempts=cfg.max_attempts,
|
|
108
|
+
last_status=response.status_code,
|
|
109
|
+
)
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
113
|
+
logger.warning(
|
|
114
|
+
"retryhttp: HTTP %d on attempt %d/%d, retrying in %.2fs",
|
|
115
|
+
response.status_code,
|
|
116
|
+
attempt + 1,
|
|
117
|
+
cfg.max_attempts,
|
|
118
|
+
delay,
|
|
119
|
+
)
|
|
120
|
+
time.sleep(delay)
|
|
121
|
+
|
|
122
|
+
# Should only be reached via network-error path exhaustion.
|
|
123
|
+
if cfg.raise_on_exhaust:
|
|
124
|
+
raise RetryExhausted(
|
|
125
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
126
|
+
attempts=cfg.max_attempts,
|
|
127
|
+
last_status=last_response.status_code if last_response else None,
|
|
128
|
+
)
|
|
129
|
+
assert last_response is not None
|
|
130
|
+
return last_response
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AsyncRetryClient(httpx.AsyncClient):
|
|
134
|
+
"""An async httpx.AsyncClient that retries with exponential backoff.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
config: Retry policy.
|
|
138
|
+
token_refresher: Optional *async* callable ``async () -> str``.
|
|
139
|
+
**kwargs: Forwarded to httpx.AsyncClient.
|
|
140
|
+
|
|
141
|
+
Example::
|
|
142
|
+
|
|
143
|
+
async def refresh() -> str:
|
|
144
|
+
return await fetch_new_token_async()
|
|
145
|
+
|
|
146
|
+
async with AsyncRetryClient(token_refresher=refresh) as client:
|
|
147
|
+
resp = await client.get("https://api.example.com/data")
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
config: RetryConfig | None = None,
|
|
154
|
+
token_refresher: AsyncTokenRefresher | None = None,
|
|
155
|
+
**kwargs: Any,
|
|
156
|
+
) -> None:
|
|
157
|
+
super().__init__(**kwargs)
|
|
158
|
+
self.retry_config = config or RetryConfig()
|
|
159
|
+
self._token_refresher = token_refresher
|
|
160
|
+
|
|
161
|
+
async def _maybe_refresh_token(self) -> None:
|
|
162
|
+
if self._token_refresher is not None:
|
|
163
|
+
if asyncio.iscoroutinefunction(self._token_refresher):
|
|
164
|
+
new_token = await self._token_refresher()
|
|
165
|
+
else:
|
|
166
|
+
new_token = self._token_refresher()
|
|
167
|
+
self.headers["Authorization"] = f"Bearer {new_token}"
|
|
168
|
+
logger.debug("retryhttp: token refreshed (async)")
|
|
169
|
+
|
|
170
|
+
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response: # type: ignore[override]
|
|
171
|
+
cfg = self.retry_config
|
|
172
|
+
last_response: httpx.Response | None = None
|
|
173
|
+
|
|
174
|
+
for attempt in range(cfg.max_attempts):
|
|
175
|
+
try:
|
|
176
|
+
response = await super().send(request, **kwargs)
|
|
177
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
178
|
+
if not cfg.retry_on_network_error or attempt == cfg.max_attempts - 1:
|
|
179
|
+
raise
|
|
180
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
181
|
+
logger.warning(
|
|
182
|
+
"retryhttp: network error on attempt %d/%d (%s), retrying in %.2fs",
|
|
183
|
+
attempt + 1,
|
|
184
|
+
cfg.max_attempts,
|
|
185
|
+
exc,
|
|
186
|
+
delay,
|
|
187
|
+
)
|
|
188
|
+
await asyncio.sleep(delay)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
last_response = response
|
|
192
|
+
|
|
193
|
+
if cfg.token_refresh_status and response.status_code == cfg.token_refresh_status:
|
|
194
|
+
if attempt < cfg.max_attempts - 1:
|
|
195
|
+
logger.info("retryhttp: 401 received, refreshing token (attempt %d)", attempt + 1)
|
|
196
|
+
await self._maybe_refresh_token()
|
|
197
|
+
request = self.build_request(
|
|
198
|
+
method=request.method,
|
|
199
|
+
url=request.url,
|
|
200
|
+
content=request.content,
|
|
201
|
+
headers=dict(request.headers),
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if not cfg.should_retry_status(response.status_code):
|
|
206
|
+
return response
|
|
207
|
+
|
|
208
|
+
if attempt == cfg.max_attempts - 1:
|
|
209
|
+
if cfg.raise_on_exhaust:
|
|
210
|
+
raise RetryExhausted(
|
|
211
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
212
|
+
attempts=cfg.max_attempts,
|
|
213
|
+
last_status=response.status_code,
|
|
214
|
+
)
|
|
215
|
+
return response
|
|
216
|
+
|
|
217
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
218
|
+
logger.warning(
|
|
219
|
+
"retryhttp: HTTP %d on attempt %d/%d, retrying in %.2fs",
|
|
220
|
+
response.status_code,
|
|
221
|
+
attempt + 1,
|
|
222
|
+
cfg.max_attempts,
|
|
223
|
+
delay,
|
|
224
|
+
)
|
|
225
|
+
await asyncio.sleep(delay)
|
|
226
|
+
|
|
227
|
+
if cfg.raise_on_exhaust:
|
|
228
|
+
raise RetryExhausted(
|
|
229
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
230
|
+
attempts=cfg.max_attempts,
|
|
231
|
+
last_status=last_response.status_code if last_response else None,
|
|
232
|
+
)
|
|
233
|
+
assert last_response is not None
|
|
234
|
+
return last_response
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""requests adapter: RetrySession."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from requests import PreparedRequest, Response
|
|
12
|
+
from requests.adapters import HTTPAdapter
|
|
13
|
+
|
|
14
|
+
from retryhttp.backoff import backoff_delay
|
|
15
|
+
from retryhttp.retry import RetryConfig, RetryExhausted
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
TokenRefresher = Callable[[], str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _RetryAdapter(HTTPAdapter):
|
|
23
|
+
"""Internal HTTPAdapter that carries a RetryConfig.
|
|
24
|
+
|
|
25
|
+
The actual retry loop lives in RetrySession.send so that the token-refresh
|
|
26
|
+
hook has access to the session-level headers.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: RetryConfig, **kwargs: Any) -> None:
|
|
30
|
+
self.retry_config = config
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RetrySession(requests.Session):
|
|
35
|
+
"""A requests.Session that retries failed requests with exponential backoff.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config: Retry policy. Defaults to RetryConfig() (3 attempts, sane backoff).
|
|
39
|
+
token_refresher: Optional callable ``() -> str`` that returns a fresh
|
|
40
|
+
bearer token. Triggered on 401 responses.
|
|
41
|
+
**kwargs: Not used; present for forward-compatibility.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
def refresh() -> str:
|
|
46
|
+
return fetch_new_token()
|
|
47
|
+
|
|
48
|
+
with RetrySession(token_refresher=refresh) as session:
|
|
49
|
+
resp = session.get("https://api.example.com/data")
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
config: RetryConfig | None = None,
|
|
56
|
+
token_refresher: TokenRefresher | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.retry_config = config or RetryConfig()
|
|
60
|
+
self._token_refresher = token_refresher
|
|
61
|
+
|
|
62
|
+
adapter = _RetryAdapter(self.retry_config)
|
|
63
|
+
self.mount("https://", adapter)
|
|
64
|
+
self.mount("http://", adapter)
|
|
65
|
+
|
|
66
|
+
def _maybe_refresh_token(self) -> None:
|
|
67
|
+
if self._token_refresher is not None:
|
|
68
|
+
new_token = self._token_refresher()
|
|
69
|
+
self.headers["Authorization"] = f"Bearer {new_token}"
|
|
70
|
+
logger.debug("retryhttp: token refreshed")
|
|
71
|
+
|
|
72
|
+
def send(self, request: PreparedRequest, **kwargs: Any) -> Response: # type: ignore[override]
|
|
73
|
+
cfg = self.retry_config
|
|
74
|
+
last_response: Response | None = None
|
|
75
|
+
|
|
76
|
+
for attempt in range(cfg.max_attempts):
|
|
77
|
+
try:
|
|
78
|
+
response = super().send(request, **kwargs)
|
|
79
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
80
|
+
if not cfg.retry_on_network_error or attempt == cfg.max_attempts - 1:
|
|
81
|
+
raise
|
|
82
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
83
|
+
logger.warning(
|
|
84
|
+
"retryhttp: network error on attempt %d/%d (%s), retrying in %.2fs",
|
|
85
|
+
attempt + 1,
|
|
86
|
+
cfg.max_attempts,
|
|
87
|
+
exc,
|
|
88
|
+
delay,
|
|
89
|
+
)
|
|
90
|
+
time.sleep(delay)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
last_response = response
|
|
94
|
+
|
|
95
|
+
# Token refresh on 401.
|
|
96
|
+
if cfg.token_refresh_status and response.status_code == cfg.token_refresh_status:
|
|
97
|
+
if attempt < cfg.max_attempts - 1:
|
|
98
|
+
logger.info("retryhttp: 401 received, refreshing token (attempt %d)", attempt + 1)
|
|
99
|
+
self._maybe_refresh_token()
|
|
100
|
+
# Re-prepare the request so the updated session headers are baked in.
|
|
101
|
+
req = response.request
|
|
102
|
+
assert req.url is not None
|
|
103
|
+
prepped = self.prepare_request(
|
|
104
|
+
requests.Request(
|
|
105
|
+
method=req.method or "GET",
|
|
106
|
+
url=req.url,
|
|
107
|
+
headers=dict(self.headers),
|
|
108
|
+
data=req.body,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
request = prepped
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if not cfg.should_retry_status(response.status_code):
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
if attempt == cfg.max_attempts - 1:
|
|
118
|
+
if cfg.raise_on_exhaust:
|
|
119
|
+
raise RetryExhausted(
|
|
120
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
121
|
+
attempts=cfg.max_attempts,
|
|
122
|
+
last_status=response.status_code,
|
|
123
|
+
)
|
|
124
|
+
return response
|
|
125
|
+
|
|
126
|
+
delay = backoff_delay(attempt, base=cfg.backoff_base, maximum=cfg.backoff_max, jitter=cfg.jitter)
|
|
127
|
+
logger.warning(
|
|
128
|
+
"retryhttp: HTTP %d on attempt %d/%d, retrying in %.2fs",
|
|
129
|
+
response.status_code,
|
|
130
|
+
attempt + 1,
|
|
131
|
+
cfg.max_attempts,
|
|
132
|
+
delay,
|
|
133
|
+
)
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
|
|
136
|
+
if cfg.raise_on_exhaust:
|
|
137
|
+
raise RetryExhausted(
|
|
138
|
+
f"All {cfg.max_attempts} attempts failed",
|
|
139
|
+
attempts=cfg.max_attempts,
|
|
140
|
+
last_status=last_response.status_code if last_response else None,
|
|
141
|
+
)
|
|
142
|
+
assert last_response is not None
|
|
143
|
+
return last_response
|
retryhttp/retry.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Core retry configuration and exception types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# HTTP status codes that are safe to retry by default.
|
|
9
|
+
DEFAULT_RETRY_STATUSES: frozenset[int] = frozenset(
|
|
10
|
+
{
|
|
11
|
+
429, # Too Many Requests
|
|
12
|
+
500, # Internal Server Error
|
|
13
|
+
502, # Bad Gateway
|
|
14
|
+
503, # Service Unavailable
|
|
15
|
+
504, # Gateway Timeout
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RetryExhausted(Exception):
|
|
21
|
+
"""Raised when all retry attempts have been spent.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
attempts: Number of attempts made before giving up.
|
|
25
|
+
last_status: HTTP status code of the final response, or None if the
|
|
26
|
+
last attempt raised a network-level exception.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
attempts: int,
|
|
34
|
+
last_status: int | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.attempts = attempts
|
|
38
|
+
self.last_status = last_status
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RetryConfig:
|
|
43
|
+
"""Declarative retry policy passed to RetryClient / RetrySession.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
max_attempts: Total number of attempts (1 = no retries).
|
|
47
|
+
retry_statuses: Set of HTTP status codes that trigger a retry.
|
|
48
|
+
backoff_base: Base for exponential backoff in seconds.
|
|
49
|
+
backoff_max: Maximum backoff ceiling in seconds.
|
|
50
|
+
jitter: When True, adds random jitter to each backoff delay.
|
|
51
|
+
retry_on_network_error: Retry on connection errors, timeouts, etc.
|
|
52
|
+
token_refresh_status: Status code that triggers a token refresh
|
|
53
|
+
(default 401). Set to None to disable.
|
|
54
|
+
raise_on_exhaust: Raise RetryExhausted when all attempts fail.
|
|
55
|
+
When False, the final bad response is returned instead.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
config = RetryConfig(max_attempts=5, backoff_base=0.5)
|
|
60
|
+
client = RetryClient(config=config, token_refresher=my_refresh_fn)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
max_attempts: int = 3
|
|
64
|
+
retry_statuses: frozenset[int] = field(default_factory=lambda: DEFAULT_RETRY_STATUSES)
|
|
65
|
+
backoff_base: float = 1.0
|
|
66
|
+
backoff_max: float = 60.0
|
|
67
|
+
jitter: bool = True
|
|
68
|
+
retry_on_network_error: bool = True
|
|
69
|
+
token_refresh_status: int | None = 401
|
|
70
|
+
raise_on_exhaust: bool = True
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.max_attempts < 1:
|
|
74
|
+
raise ValueError("max_attempts must be >= 1")
|
|
75
|
+
if self.backoff_base <= 0:
|
|
76
|
+
raise ValueError("backoff_base must be positive")
|
|
77
|
+
if self.backoff_max < self.backoff_base:
|
|
78
|
+
raise ValueError("backoff_max must be >= backoff_base")
|
|
79
|
+
|
|
80
|
+
def should_retry_status(self, status: int) -> bool:
|
|
81
|
+
"""Return True if *status* is in the retry set."""
|
|
82
|
+
return status in self.retry_statuses
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: retryhttp2
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-config HTTP retries with exponential backoff, jitter, and OAuth2 token refresh for httpx and requests
|
|
5
|
+
Project-URL: Homepage, https://github.com/praneethv2003/retryhttp
|
|
6
|
+
Project-URL: Repository, https://github.com/praneethv2003/retryhttp
|
|
7
|
+
Project-URL: Issues, https://github.com/praneethv2003/retryhttp/issues
|
|
8
|
+
Author-email: Praneeth Vedantham <praneethvedantham@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: backoff,http,httpx,oauth2,requests,retry
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: httpx>=0.25; extra == 'all'
|
|
25
|
+
Requires-Dist: requests>=2.28; extra == 'all'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
28
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-httpx>=0.28; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
32
|
+
Requires-Dist: requests>=2.28; extra == 'dev'
|
|
33
|
+
Requires-Dist: responses>=0.24; extra == 'dev'
|
|
34
|
+
Provides-Extra: httpx
|
|
35
|
+
Requires-Dist: httpx>=0.25; extra == 'httpx'
|
|
36
|
+
Provides-Extra: requests
|
|
37
|
+
Requires-Dist: requests>=2.28; extra == 'requests'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# retryhttp
|
|
41
|
+
|
|
42
|
+
Zero-config HTTP retries with exponential backoff, jitter, and OAuth2 token refresh — for both **httpx** and **requests**.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from retryhttp import RetryClient
|
|
46
|
+
|
|
47
|
+
with RetryClient(token_refresher=lambda: fetch_token()) as client:
|
|
48
|
+
resp = client.get("https://api.example.com/data")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why
|
|
52
|
+
|
|
53
|
+
Every team rewrites retry logic from scratch. `retryhttp` gives you:
|
|
54
|
+
|
|
55
|
+
- **Exponential backoff with full jitter** (AWS-recommended strategy)
|
|
56
|
+
- **Token refresh hooks** — pass a callable, 401s are handled automatically
|
|
57
|
+
- **Per-status-code retry policies** — retry 503 but not 404
|
|
58
|
+
- **Network error retries** — connection errors and timeouts included
|
|
59
|
+
- **Async support** via `AsyncRetryClient`
|
|
60
|
+
- **Drop-in replacements** — subclasses `httpx.Client` and `requests.Session`, so existing code works unchanged
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# httpx support
|
|
66
|
+
pip install "retryhttp[httpx]"
|
|
67
|
+
|
|
68
|
+
# requests support
|
|
69
|
+
pip install "retryhttp[requests]"
|
|
70
|
+
|
|
71
|
+
# both
|
|
72
|
+
pip install "retryhttp[all]"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### httpx (sync)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from retryhttp import RetryClient, RetryConfig
|
|
81
|
+
|
|
82
|
+
config = RetryConfig(
|
|
83
|
+
max_attempts=5,
|
|
84
|
+
backoff_base=0.5,
|
|
85
|
+
backoff_max=30.0,
|
|
86
|
+
retry_statuses=frozenset({429, 500, 502, 503, 504}),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get_token() -> str:
|
|
90
|
+
# call your auth server
|
|
91
|
+
return "new-bearer-token"
|
|
92
|
+
|
|
93
|
+
with RetryClient(config=config, token_refresher=get_token) as client:
|
|
94
|
+
resp = client.get("https://api.example.com/resource")
|
|
95
|
+
print(resp.json())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### httpx (async)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import asyncio
|
|
102
|
+
from retryhttp import AsyncRetryClient
|
|
103
|
+
|
|
104
|
+
async def get_token() -> str:
|
|
105
|
+
return "new-bearer-token"
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
async with AsyncRetryClient(token_refresher=get_token) as client:
|
|
109
|
+
resp = await client.get("https://api.example.com/resource")
|
|
110
|
+
print(resp.json())
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### requests
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from retryhttp import RetrySession
|
|
119
|
+
|
|
120
|
+
with RetrySession(token_refresher=lambda: "new-token") as session:
|
|
121
|
+
resp = session.get("https://api.example.com/resource")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
`RetryConfig` controls all retry behaviour:
|
|
127
|
+
|
|
128
|
+
| Parameter | Default | Description |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `max_attempts` | `3` | Total attempts (1 = no retries) |
|
|
131
|
+
| `retry_statuses` | `{429,500,502,503,504}` | Status codes that trigger a retry |
|
|
132
|
+
| `backoff_base` | `1.0` | Base backoff in seconds |
|
|
133
|
+
| `backoff_max` | `60.0` | Maximum backoff ceiling |
|
|
134
|
+
| `jitter` | `True` | Full jitter on each delay |
|
|
135
|
+
| `retry_on_network_error` | `True` | Retry on connection/timeout errors |
|
|
136
|
+
| `token_refresh_status` | `401` | Status that triggers token refresh |
|
|
137
|
+
| `raise_on_exhaust` | `True` | Raise `RetryExhausted` when all attempts fail; `False` returns the last response |
|
|
138
|
+
|
|
139
|
+
## How backoff works
|
|
140
|
+
|
|
141
|
+
Uses AWS's **Full Jitter** strategy:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
cap = min(backoff_max, backoff_base × 2^attempt)
|
|
145
|
+
sleep = random(0, cap)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This avoids thundering-herd problems when many clients retry simultaneously.
|
|
149
|
+
|
|
150
|
+
## Exceptions
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from retryhttp import RetryExhausted
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
client.get("https://api.example.com")
|
|
157
|
+
except RetryExhausted as e:
|
|
158
|
+
print(f"Failed after {e.attempts} attempts, last status: {e.last_status}")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
git clone https://github.com/praneethvedantham/retryhttp
|
|
165
|
+
cd retryhttp
|
|
166
|
+
pip install -e ".[dev]"
|
|
167
|
+
pytest
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
retryhttp/__init__.py,sha256=uVRU2vOqBoxCY-zv_HMOjKnb8kgEU2SwCbGLnDTtP88,526
|
|
2
|
+
retryhttp/backoff.py,sha256=l4QmfPND81Eo555eFXCXV-kn6S3SXqZlfvGcuVd7j58,1038
|
|
3
|
+
retryhttp/httpx_client.py,sha256=GPzaxSvFHjKAGEtKPinVDT6ZHY-1bVPbqlUVB9OrQGI,8951
|
|
4
|
+
retryhttp/requests_client.py,sha256=PeyjBlzZE_5E3mYr3bRaB-NbLKObU_tB0mjMsWauEyQ,5172
|
|
5
|
+
retryhttp/retry.py,sha256=FcYNoXx0Iq0zHodcBchkCHbHTAtiiIH5gIaJpoQkA_c,2742
|
|
6
|
+
retryhttp2-0.1.0.dist-info/METADATA,sha256=YP_4R4PrvfUSKFqVuj-oskL0G5oE-30tWkT-JMWaqxE,4916
|
|
7
|
+
retryhttp2-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
retryhttp2-0.1.0.dist-info/licenses/LICENSE,sha256=6uC0KNvtqc6G9cmYKzcEFwWDztGHBa_RTncT8jKpwkc,1075
|
|
9
|
+
retryhttp2-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Praneeth Vedantham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|