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.
- retryhttp2-0.1.0/.github/workflows/ci.yml +59 -0
- retryhttp2-0.1.0/.gitignore +10 -0
- retryhttp2-0.1.0/LICENSE +21 -0
- retryhttp2-0.1.0/PKG-INFO +172 -0
- retryhttp2-0.1.0/README.md +133 -0
- retryhttp2-0.1.0/pyproject.toml +55 -0
- retryhttp2-0.1.0/src/retryhttp/__init__.py +20 -0
- retryhttp2-0.1.0/src/retryhttp/backoff.py +39 -0
- retryhttp2-0.1.0/src/retryhttp/httpx_client.py +234 -0
- retryhttp2-0.1.0/src/retryhttp/requests_client.py +143 -0
- retryhttp2-0.1.0/src/retryhttp/retry.py +82 -0
- retryhttp2-0.1.0/tests/__init__.py +0 -0
- retryhttp2-0.1.0/tests/test_backoff.py +27 -0
- retryhttp2-0.1.0/tests/test_httpx_client.py +142 -0
- retryhttp2-0.1.0/tests/test_requests_client.py +84 -0
- retryhttp2-0.1.0/tests/test_retry_config.py +40 -0
|
@@ -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
|
retryhttp2-0.1.0/LICENSE
ADDED
|
@@ -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)
|