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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.