retryhttp2 0.1.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.
@@ -0,0 +1,59 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ release:
9
+ types: [published]
10
+
11
+ jobs:
12
+ test:
13
+ name: Python ${{ matrix.python-version }}
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.10", "3.11", "3.12"]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ pip install -e ".[dev]"
31
+
32
+ - name: Run tests
33
+ run: pytest --tb=short -q
34
+
35
+ publish:
36
+ name: Publish to PyPI
37
+ needs: test
38
+ runs-on: ubuntu-latest
39
+ if: github.event_name == 'release'
40
+ environment: pypi
41
+ permissions:
42
+ id-token: write # Required for trusted publishing
43
+
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - name: Set up Python
48
+ uses: actions/setup-python@v5
49
+ with:
50
+ python-version: "3.12"
51
+
52
+ - name: Install build tools
53
+ run: pip install hatch
54
+
55
+ - name: Build package
56
+ run: hatch build
57
+
58
+ - name: Publish to PyPI
59
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ *.so
@@ -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.
@@ -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,133 @@
1
+ # retryhttp
2
+
3
+ Zero-config HTTP retries with exponential backoff, jitter, and OAuth2 token refresh — for both **httpx** and **requests**.
4
+
5
+ ```python
6
+ from retryhttp import RetryClient
7
+
8
+ with RetryClient(token_refresher=lambda: fetch_token()) as client:
9
+ resp = client.get("https://api.example.com/data")
10
+ ```
11
+
12
+ ## Why
13
+
14
+ Every team rewrites retry logic from scratch. `retryhttp` gives you:
15
+
16
+ - **Exponential backoff with full jitter** (AWS-recommended strategy)
17
+ - **Token refresh hooks** — pass a callable, 401s are handled automatically
18
+ - **Per-status-code retry policies** — retry 503 but not 404
19
+ - **Network error retries** — connection errors and timeouts included
20
+ - **Async support** via `AsyncRetryClient`
21
+ - **Drop-in replacements** — subclasses `httpx.Client` and `requests.Session`, so existing code works unchanged
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ # httpx support
27
+ pip install "retryhttp[httpx]"
28
+
29
+ # requests support
30
+ pip install "retryhttp[requests]"
31
+
32
+ # both
33
+ pip install "retryhttp[all]"
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### httpx (sync)
39
+
40
+ ```python
41
+ from retryhttp import RetryClient, RetryConfig
42
+
43
+ config = RetryConfig(
44
+ max_attempts=5,
45
+ backoff_base=0.5,
46
+ backoff_max=30.0,
47
+ retry_statuses=frozenset({429, 500, 502, 503, 504}),
48
+ )
49
+
50
+ def get_token() -> str:
51
+ # call your auth server
52
+ return "new-bearer-token"
53
+
54
+ with RetryClient(config=config, token_refresher=get_token) as client:
55
+ resp = client.get("https://api.example.com/resource")
56
+ print(resp.json())
57
+ ```
58
+
59
+ ### httpx (async)
60
+
61
+ ```python
62
+ import asyncio
63
+ from retryhttp import AsyncRetryClient
64
+
65
+ async def get_token() -> str:
66
+ return "new-bearer-token"
67
+
68
+ async def main():
69
+ async with AsyncRetryClient(token_refresher=get_token) as client:
70
+ resp = await client.get("https://api.example.com/resource")
71
+ print(resp.json())
72
+
73
+ asyncio.run(main())
74
+ ```
75
+
76
+ ### requests
77
+
78
+ ```python
79
+ from retryhttp import RetrySession
80
+
81
+ with RetrySession(token_refresher=lambda: "new-token") as session:
82
+ resp = session.get("https://api.example.com/resource")
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ `RetryConfig` controls all retry behaviour:
88
+
89
+ | Parameter | Default | Description |
90
+ |---|---|---|
91
+ | `max_attempts` | `3` | Total attempts (1 = no retries) |
92
+ | `retry_statuses` | `{429,500,502,503,504}` | Status codes that trigger a retry |
93
+ | `backoff_base` | `1.0` | Base backoff in seconds |
94
+ | `backoff_max` | `60.0` | Maximum backoff ceiling |
95
+ | `jitter` | `True` | Full jitter on each delay |
96
+ | `retry_on_network_error` | `True` | Retry on connection/timeout errors |
97
+ | `token_refresh_status` | `401` | Status that triggers token refresh |
98
+ | `raise_on_exhaust` | `True` | Raise `RetryExhausted` when all attempts fail; `False` returns the last response |
99
+
100
+ ## How backoff works
101
+
102
+ Uses AWS's **Full Jitter** strategy:
103
+
104
+ ```
105
+ cap = min(backoff_max, backoff_base × 2^attempt)
106
+ sleep = random(0, cap)
107
+ ```
108
+
109
+ This avoids thundering-herd problems when many clients retry simultaneously.
110
+
111
+ ## Exceptions
112
+
113
+ ```python
114
+ from retryhttp import RetryExhausted
115
+
116
+ try:
117
+ client.get("https://api.example.com")
118
+ except RetryExhausted as e:
119
+ print(f"Failed after {e.attempts} attempts, last status: {e.last_status}")
120
+ ```
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ git clone https://github.com/praneethvedantham/retryhttp
126
+ cd retryhttp
127
+ pip install -e ".[dev]"
128
+ pytest
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "retryhttp2"
7
+ version = "0.1.0"
8
+ description = "Zero-config HTTP retries with exponential backoff, jitter, and OAuth2 token refresh for httpx and requests"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Praneeth Vedantham", email = "praneethvedantham@gmail.com" }]
13
+ keywords = ["http", "retry", "backoff", "httpx", "requests", "oauth2"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Internet :: WWW/HTTP",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ httpx = ["httpx>=0.25"]
30
+ requests = ["requests>=2.28"]
31
+ all = ["httpx>=0.25", "requests>=2.28"]
32
+ dev = [
33
+ "httpx>=0.25",
34
+ "requests>=2.28",
35
+ "pytest>=7.4",
36
+ "pytest-asyncio>=0.23",
37
+ "pytest-httpx>=0.28",
38
+ "responses>=0.24",
39
+ "hatch",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/praneethv2003/retryhttp"
44
+ Repository = "https://github.com/praneethv2003/retryhttp"
45
+ Issues = "https://github.com/praneethv2003/retryhttp/issues"
46
+
47
+ [tool.hatch.metadata]
48
+ allow-direct-references = true
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/retryhttp"]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]
@@ -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
@@ -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
@@ -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
File without changes
@@ -0,0 +1,27 @@
1
+ """Tests for the backoff delay calculation."""
2
+
3
+ import pytest
4
+
5
+ from retryhttp.backoff import backoff_delay
6
+
7
+
8
+ def test_no_jitter_grows_exponentially():
9
+ delays = [backoff_delay(i, base=1.0, maximum=60.0, jitter=False) for i in range(5)]
10
+ assert delays == [1.0, 2.0, 4.0, 8.0, 16.0]
11
+
12
+
13
+ def test_maximum_caps_delay():
14
+ delay = backoff_delay(100, base=1.0, maximum=10.0, jitter=False)
15
+ assert delay == 10.0
16
+
17
+
18
+ def test_jitter_within_bounds():
19
+ for attempt in range(10):
20
+ d = backoff_delay(attempt, base=1.0, maximum=60.0, jitter=True)
21
+ cap = min(60.0, 1.0 * (2**attempt))
22
+ assert 0.0 <= d <= cap
23
+
24
+
25
+ def test_base_zero_raises():
26
+ with pytest.raises(Exception):
27
+ backoff_delay(0, base=0, maximum=10.0)
@@ -0,0 +1,142 @@
1
+ """Tests for RetryClient and AsyncRetryClient (httpx)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import httpx
8
+ import pytest
9
+ from pytest_httpx import HTTPXMock
10
+
11
+ from retryhttp import AsyncRetryClient, RetryClient
12
+ from retryhttp.retry import RetryConfig, RetryExhausted
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Sync RetryClient
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def test_successful_request(httpx_mock: HTTPXMock):
21
+ httpx_mock.add_response(status_code=200, json={"ok": True})
22
+ with RetryClient() as client:
23
+ resp = client.get("https://example.com/api")
24
+ assert resp.status_code == 200
25
+
26
+
27
+ def test_retries_on_503(httpx_mock: HTTPXMock):
28
+ httpx_mock.add_response(status_code=503)
29
+ httpx_mock.add_response(status_code=503)
30
+ httpx_mock.add_response(status_code=200, json={"ok": True})
31
+
32
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
33
+ with RetryClient(config=cfg) as client:
34
+ resp = client.get("https://example.com/api")
35
+ assert resp.status_code == 200
36
+
37
+
38
+ def test_raises_retry_exhausted(httpx_mock: HTTPXMock):
39
+ httpx_mock.add_response(status_code=503)
40
+ httpx_mock.add_response(status_code=503)
41
+ httpx_mock.add_response(status_code=503)
42
+
43
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
44
+ with pytest.raises(RetryExhausted) as exc_info:
45
+ with RetryClient(config=cfg) as client:
46
+ client.get("https://example.com/api")
47
+
48
+ assert exc_info.value.attempts == 3
49
+ assert exc_info.value.last_status == 503
50
+
51
+
52
+ def test_no_retry_on_404(httpx_mock: HTTPXMock):
53
+ httpx_mock.add_response(status_code=404)
54
+
55
+ cfg = RetryConfig(max_attempts=3)
56
+ with RetryClient(config=cfg) as client:
57
+ resp = client.get("https://example.com/api")
58
+ assert resp.status_code == 404
59
+ # Only one request should have been made.
60
+ assert len(httpx_mock.get_requests()) == 1
61
+
62
+
63
+ def test_token_refresh_on_401(httpx_mock: HTTPXMock):
64
+ httpx_mock.add_response(status_code=401)
65
+ httpx_mock.add_response(status_code=200, json={"ok": True})
66
+
67
+ refresh_calls: list[str] = []
68
+
69
+ def refresher() -> str:
70
+ refresh_calls.append("called")
71
+ return "new-token-xyz"
72
+
73
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
74
+ with RetryClient(config=cfg, token_refresher=refresher) as client:
75
+ resp = client.get("https://example.com/api")
76
+
77
+ assert resp.status_code == 200
78
+ assert len(refresh_calls) == 1
79
+ assert client.headers["Authorization"] == "Bearer new-token-xyz"
80
+
81
+
82
+ def test_raise_on_exhaust_false_returns_response(httpx_mock: HTTPXMock):
83
+ httpx_mock.add_response(status_code=503)
84
+ httpx_mock.add_response(status_code=503)
85
+ httpx_mock.add_response(status_code=503)
86
+
87
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001, raise_on_exhaust=False)
88
+ with RetryClient(config=cfg) as client:
89
+ resp = client.get("https://example.com/api")
90
+ assert resp.status_code == 503
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Async AsyncRetryClient
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_async_successful_request(httpx_mock: HTTPXMock):
100
+ httpx_mock.add_response(status_code=200, json={"ok": True})
101
+ async with AsyncRetryClient() as client:
102
+ resp = await client.get("https://example.com/api")
103
+ assert resp.status_code == 200
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_async_retries_on_503(httpx_mock: HTTPXMock):
108
+ httpx_mock.add_response(status_code=503)
109
+ httpx_mock.add_response(status_code=200, json={"ok": True})
110
+
111
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
112
+ async with AsyncRetryClient(config=cfg) as client:
113
+ resp = await client.get("https://example.com/api")
114
+ assert resp.status_code == 200
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_async_token_refresh_on_401(httpx_mock: HTTPXMock):
119
+ httpx_mock.add_response(status_code=401)
120
+ httpx_mock.add_response(status_code=200, json={"ok": True})
121
+
122
+ async def async_refresher() -> str:
123
+ return "async-new-token"
124
+
125
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
126
+ async with AsyncRetryClient(config=cfg, token_refresher=async_refresher) as client:
127
+ resp = await client.get("https://example.com/api")
128
+
129
+ assert resp.status_code == 200
130
+ assert client.headers["Authorization"] == "Bearer async-new-token"
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_async_raises_retry_exhausted(httpx_mock: HTTPXMock):
135
+ httpx_mock.add_response(status_code=503)
136
+ httpx_mock.add_response(status_code=503)
137
+ httpx_mock.add_response(status_code=503)
138
+
139
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
140
+ with pytest.raises(RetryExhausted):
141
+ async with AsyncRetryClient(config=cfg) as client:
142
+ await client.get("https://example.com/api")
@@ -0,0 +1,84 @@
1
+ """Tests for RetrySession (requests)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ import responses as resp_mock
7
+ from responses import RequestsMock
8
+
9
+ from retryhttp import RetrySession
10
+ from retryhttp.retry import RetryConfig, RetryExhausted
11
+
12
+
13
+ @resp_mock.activate
14
+ def test_successful_request():
15
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=200, json={"ok": True})
16
+ with RetrySession() as session:
17
+ resp = session.get("https://example.com/api")
18
+ assert resp.status_code == 200
19
+
20
+
21
+ @resp_mock.activate
22
+ def test_retries_on_503():
23
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=503)
24
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=503)
25
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=200, json={"ok": True})
26
+
27
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
28
+ with RetrySession(config=cfg) as session:
29
+ resp = session.get("https://example.com/api")
30
+ assert resp.status_code == 200
31
+
32
+
33
+ @resp_mock.activate
34
+ def test_raises_retry_exhausted():
35
+ for _ in range(3):
36
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=503)
37
+
38
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
39
+ with pytest.raises(RetryExhausted) as exc_info:
40
+ with RetrySession(config=cfg) as session:
41
+ session.get("https://example.com/api")
42
+
43
+ assert exc_info.value.attempts == 3
44
+ assert exc_info.value.last_status == 503
45
+
46
+
47
+ @resp_mock.activate
48
+ def test_no_retry_on_404():
49
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=404)
50
+
51
+ with RetrySession() as session:
52
+ resp = session.get("https://example.com/api")
53
+ assert resp.status_code == 404
54
+ assert len(resp_mock.calls) == 1
55
+
56
+
57
+ @resp_mock.activate
58
+ def test_token_refresh_on_401():
59
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=401)
60
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=200, json={"ok": True})
61
+
62
+ calls: list[str] = []
63
+
64
+ def refresher() -> str:
65
+ calls.append("called")
66
+ return "fresh-token"
67
+
68
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001)
69
+ with RetrySession(config=cfg, token_refresher=refresher) as session:
70
+ resp = session.get("https://example.com/api")
71
+
72
+ assert resp.status_code == 200
73
+ assert len(calls) == 1
74
+
75
+
76
+ @resp_mock.activate
77
+ def test_raise_on_exhaust_false_returns_response():
78
+ for _ in range(3):
79
+ resp_mock.add(resp_mock.GET, "https://example.com/api", status=503)
80
+
81
+ cfg = RetryConfig(max_attempts=3, jitter=False, backoff_base=0.0001, raise_on_exhaust=False)
82
+ with RetrySession(config=cfg) as session:
83
+ resp = session.get("https://example.com/api")
84
+ assert resp.status_code == 503
@@ -0,0 +1,40 @@
1
+ """Tests for RetryConfig validation and behaviour."""
2
+
3
+ import pytest
4
+
5
+ from retryhttp.retry import DEFAULT_RETRY_STATUSES, RetryConfig
6
+
7
+
8
+ def test_defaults():
9
+ cfg = RetryConfig()
10
+ assert cfg.max_attempts == 3
11
+ assert cfg.jitter is True
12
+ assert cfg.retry_statuses == DEFAULT_RETRY_STATUSES
13
+
14
+
15
+ def test_should_retry_status():
16
+ cfg = RetryConfig()
17
+ assert cfg.should_retry_status(503) is True
18
+ assert cfg.should_retry_status(200) is False
19
+ assert cfg.should_retry_status(404) is False
20
+
21
+
22
+ def test_invalid_max_attempts():
23
+ with pytest.raises(ValueError, match="max_attempts"):
24
+ RetryConfig(max_attempts=0)
25
+
26
+
27
+ def test_invalid_backoff_base():
28
+ with pytest.raises(ValueError, match="backoff_base"):
29
+ RetryConfig(backoff_base=0)
30
+
31
+
32
+ def test_backoff_max_lt_base():
33
+ with pytest.raises(ValueError, match="backoff_max"):
34
+ RetryConfig(backoff_base=10.0, backoff_max=5.0)
35
+
36
+
37
+ def test_custom_retry_statuses():
38
+ cfg = RetryConfig(retry_statuses=frozenset({503, 429}))
39
+ assert cfg.should_retry_status(503)
40
+ assert not cfg.should_retry_status(500)