retryflow 1.0.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.
- retryflow-1.0.0/LICENSE +21 -0
- retryflow-1.0.0/PKG-INFO +291 -0
- retryflow-1.0.0/README.md +262 -0
- retryflow-1.0.0/pyproject.toml +39 -0
- retryflow-1.0.0/retryflow/__init__.py +11 -0
- retryflow-1.0.0/retryflow/context.py +84 -0
- retryflow-1.0.0/retryflow/core.py +323 -0
- retryflow-1.0.0/retryflow.egg-info/PKG-INFO +291 -0
- retryflow-1.0.0/retryflow.egg-info/SOURCES.txt +12 -0
- retryflow-1.0.0/retryflow.egg-info/dependency_links.txt +1 -0
- retryflow-1.0.0/retryflow.egg-info/requires.txt +4 -0
- retryflow-1.0.0/retryflow.egg-info/top_level.txt +1 -0
- retryflow-1.0.0/setup.cfg +4 -0
- retryflow-1.0.0/tests/test_core.py +251 -0
retryflow-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 prabhay759
|
|
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.
|
retryflow-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: retryflow
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Smart retry engine with exponential backoff, jitter, per-exception rules, timeout, and async support
|
|
5
|
+
Author: prabhay759
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prabhay759/retryflow
|
|
8
|
+
Project-URL: Repository, https://github.com/prabhay759/retryflow
|
|
9
|
+
Project-URL: Issues, https://github.com/prabhay759/retryflow/issues
|
|
10
|
+
Keywords: retry,backoff,resilience,async,timeout,decorator
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# retryflow
|
|
31
|
+
|
|
32
|
+
> Smart retry engine for Python — exponential backoff, jitter, per-exception rules, per-attempt timeout, hooks, and full async support.
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/retryflow/)
|
|
35
|
+
[](https://pypi.org/project/retryflow/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[]()
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why retryflow?
|
|
42
|
+
|
|
43
|
+
Every app that talks to a network, a database, or an external API will eventually hit a transient failure. `retryflow` gives you a battle-tested, zero-dependency retry engine that handles the hard parts:
|
|
44
|
+
|
|
45
|
+
- **Exponential backoff** so you don't hammer a struggling service
|
|
46
|
+
- **Jitter** to avoid thundering-herd problems across multiple clients
|
|
47
|
+
- **Per-attempt timeouts** so a single hung call can't block forever
|
|
48
|
+
- **Per-exception rules** so you only retry errors that make sense to retry
|
|
49
|
+
- **Lifecycle hooks** (`on_retry`, `on_success`, `on_failure`) for logging, alerting, metrics
|
|
50
|
+
- **Native async support** — works seamlessly with `async def` functions
|
|
51
|
+
- **Context manager API** for one-off retry blocks without decorating a function
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install retryflow
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
No dependencies. Requires Python 3.8+.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from retryflow import retry
|
|
69
|
+
|
|
70
|
+
@retry(max_attempts=3, delay=1.0, backoff=2.0)
|
|
71
|
+
def fetch_data(url):
|
|
72
|
+
response = requests.get(url, timeout=5)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
return response.json()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's it. On failure, retryflow waits 1s, then 2s, then raises `RetryError`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### 1. Basic Decorator
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from retryflow import retry
|
|
87
|
+
|
|
88
|
+
@retry(max_attempts=5, delay=0.5, backoff=2.0, jitter=0.1)
|
|
89
|
+
def call_api():
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Also works with no arguments (uses defaults):
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@retry
|
|
97
|
+
def call_api():
|
|
98
|
+
...
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 2. Per-Attempt Timeout
|
|
102
|
+
|
|
103
|
+
Stop a single attempt if it hangs beyond a time limit:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
@retry(max_attempts=3, delay=1.0, timeout=5.0)
|
|
107
|
+
def slow_query():
|
|
108
|
+
# If this takes more than 5s, a TimeoutError is raised
|
|
109
|
+
# and retryflow retries it
|
|
110
|
+
return db.execute(heavy_query)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. Retry Only Specific Exceptions
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@retry(
|
|
117
|
+
max_attempts=5,
|
|
118
|
+
delay=1.0,
|
|
119
|
+
exceptions=(ConnectionError, TimeoutError) # Only retry these
|
|
120
|
+
)
|
|
121
|
+
def connect():
|
|
122
|
+
...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Non-matching exceptions (e.g., `ValueError`) propagate immediately without retrying.
|
|
126
|
+
|
|
127
|
+
### 4. Lifecycle Hooks
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from retryflow import retry, RetryContext
|
|
131
|
+
|
|
132
|
+
def on_retry(ctx: RetryContext):
|
|
133
|
+
print(f"Attempt {ctx.attempt}/{ctx.max_attempts} failed: {ctx.exception}")
|
|
134
|
+
print(f"Retrying in {ctx.next_wait:.2f}s...")
|
|
135
|
+
|
|
136
|
+
def on_failure(ctx: RetryContext):
|
|
137
|
+
alert_team(f"{ctx.func_name} failed after {ctx.attempts} attempts")
|
|
138
|
+
|
|
139
|
+
def on_success(ctx: RetryContext):
|
|
140
|
+
metrics.record("api.success", tags={"attempt": ctx.attempt})
|
|
141
|
+
|
|
142
|
+
@retry(
|
|
143
|
+
max_attempts=5,
|
|
144
|
+
delay=1.0,
|
|
145
|
+
on_retry=on_retry,
|
|
146
|
+
on_failure=on_failure,
|
|
147
|
+
on_success=on_success,
|
|
148
|
+
)
|
|
149
|
+
def critical_call():
|
|
150
|
+
...
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### 5. Async Functions
|
|
154
|
+
|
|
155
|
+
Works exactly the same — retryflow detects `async def` automatically:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
@retry(max_attempts=4, delay=0.5, timeout=3.0)
|
|
159
|
+
async def async_fetch(url):
|
|
160
|
+
async with aiohttp.ClientSession() as session:
|
|
161
|
+
async with session.get(url) as resp:
|
|
162
|
+
return await resp.json()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 6. Reusable Config Object
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from retryflow import retry, RetryConfig
|
|
169
|
+
|
|
170
|
+
# Define once, apply everywhere
|
|
171
|
+
production_retry = RetryConfig(
|
|
172
|
+
max_attempts=5,
|
|
173
|
+
delay=1.0,
|
|
174
|
+
backoff=2.0,
|
|
175
|
+
jitter=0.3,
|
|
176
|
+
timeout=10.0,
|
|
177
|
+
exceptions=(ConnectionError, TimeoutError),
|
|
178
|
+
log_retries=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@retry(config=production_retry)
|
|
182
|
+
def service_a(): ...
|
|
183
|
+
|
|
184
|
+
@retry(config=production_retry)
|
|
185
|
+
def service_b(): ...
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 7. Context Manager
|
|
189
|
+
|
|
190
|
+
For one-off retries without decorating a function:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from retryflow import attempt
|
|
194
|
+
|
|
195
|
+
with attempt(max_attempts=3, delay=1.0, timeout=5.0) as r:
|
|
196
|
+
data = r.run(fetch, "https://api.example.com/data")
|
|
197
|
+
|
|
198
|
+
print(f"Completed in {r.elapsed:.2f}s")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## API Reference
|
|
204
|
+
|
|
205
|
+
### `@retry` decorator
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
retry(
|
|
209
|
+
func=None, # The function to wrap (when used without parentheses)
|
|
210
|
+
*,
|
|
211
|
+
max_attempts=3, # Total attempts including the first call
|
|
212
|
+
delay=1.0, # Base delay in seconds between attempts
|
|
213
|
+
backoff=2.0, # Delay multiplier (1.0=constant, 2.0=exponential)
|
|
214
|
+
jitter=0.0, # Random seconds added to each wait (0 to jitter)
|
|
215
|
+
timeout=None, # Max seconds per attempt (None = no limit)
|
|
216
|
+
exceptions=(Exception,),# Only retry on these exception types
|
|
217
|
+
on_retry=None, # Callable(RetryContext) called before each retry
|
|
218
|
+
on_failure=None, # Callable(RetryContext) called when all attempts fail
|
|
219
|
+
on_success=None, # Callable(RetryContext) called on success
|
|
220
|
+
log_retries=True, # Emit warning logs on each retry
|
|
221
|
+
config=None, # RetryConfig object (overrides all other params)
|
|
222
|
+
)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `RetryConfig`
|
|
226
|
+
|
|
227
|
+
All the same parameters as `@retry`, packaged into a reusable object.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
cfg = RetryConfig(max_attempts=5, delay=1.0, backoff=2.0, timeout=10.0)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `RetryContext`
|
|
234
|
+
|
|
235
|
+
Passed to hook callbacks with information about the current state.
|
|
236
|
+
|
|
237
|
+
| Attribute | Type | Description |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| `attempt` | `int` | Current attempt number (1-indexed) |
|
|
240
|
+
| `max_attempts` | `int` | Total configured attempts |
|
|
241
|
+
| `exception` | `Exception` | The exception that just occurred |
|
|
242
|
+
| `elapsed` | `float` | Total elapsed seconds since first attempt |
|
|
243
|
+
| `next_wait` | `float \| None` | Seconds until next retry (None on final attempt) |
|
|
244
|
+
| `func_name` | `str` | Name of the decorated function |
|
|
245
|
+
|
|
246
|
+
### `RetryError`
|
|
247
|
+
|
|
248
|
+
Raised when all attempts are exhausted.
|
|
249
|
+
|
|
250
|
+
| Attribute | Type | Description |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| `last_exception` | `Exception` | The final exception raised |
|
|
253
|
+
| `attempts` | `int` | Total number of attempts made |
|
|
254
|
+
|
|
255
|
+
### `attempt` context manager
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
with attempt(max_attempts=3, delay=1.0, ...) as r:
|
|
259
|
+
result = r.run(func, *args, **kwargs)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
After the `with` block, `r.result`, `r.elapsed`, `r.last_error`, and `r.total_attempts` are available.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Backoff Formula
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
wait = delay * (backoff ^ (attempt - 1)) + random(0, jitter)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
| attempt | delay=1, backoff=2 | delay=0.5, backoff=3 |
|
|
273
|
+
|---|---|---|
|
|
274
|
+
| 1st retry | 1.0s | 0.5s |
|
|
275
|
+
| 2nd retry | 2.0s | 1.5s |
|
|
276
|
+
| 3rd retry | 4.0s | 4.5s |
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Running Tests
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
pip install pytest pytest-asyncio
|
|
284
|
+
pytest tests/ -v
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT © prabhay759
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# retryflow
|
|
2
|
+
|
|
3
|
+
> Smart retry engine for Python — exponential backoff, jitter, per-exception rules, per-attempt timeout, hooks, and full async support.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/retryflow/)
|
|
6
|
+
[](https://pypi.org/project/retryflow/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[]()
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Why retryflow?
|
|
13
|
+
|
|
14
|
+
Every app that talks to a network, a database, or an external API will eventually hit a transient failure. `retryflow` gives you a battle-tested, zero-dependency retry engine that handles the hard parts:
|
|
15
|
+
|
|
16
|
+
- **Exponential backoff** so you don't hammer a struggling service
|
|
17
|
+
- **Jitter** to avoid thundering-herd problems across multiple clients
|
|
18
|
+
- **Per-attempt timeouts** so a single hung call can't block forever
|
|
19
|
+
- **Per-exception rules** so you only retry errors that make sense to retry
|
|
20
|
+
- **Lifecycle hooks** (`on_retry`, `on_success`, `on_failure`) for logging, alerting, metrics
|
|
21
|
+
- **Native async support** — works seamlessly with `async def` functions
|
|
22
|
+
- **Context manager API** for one-off retry blocks without decorating a function
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install retryflow
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
No dependencies. Requires Python 3.8+.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from retryflow import retry
|
|
40
|
+
|
|
41
|
+
@retry(max_attempts=3, delay=1.0, backoff=2.0)
|
|
42
|
+
def fetch_data(url):
|
|
43
|
+
response = requests.get(url, timeout=5)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it. On failure, retryflow waits 1s, then 2s, then raises `RetryError`.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### 1. Basic Decorator
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from retryflow import retry
|
|
58
|
+
|
|
59
|
+
@retry(max_attempts=5, delay=0.5, backoff=2.0, jitter=0.1)
|
|
60
|
+
def call_api():
|
|
61
|
+
...
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Also works with no arguments (uses defaults):
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
@retry
|
|
68
|
+
def call_api():
|
|
69
|
+
...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. Per-Attempt Timeout
|
|
73
|
+
|
|
74
|
+
Stop a single attempt if it hangs beyond a time limit:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
@retry(max_attempts=3, delay=1.0, timeout=5.0)
|
|
78
|
+
def slow_query():
|
|
79
|
+
# If this takes more than 5s, a TimeoutError is raised
|
|
80
|
+
# and retryflow retries it
|
|
81
|
+
return db.execute(heavy_query)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Retry Only Specific Exceptions
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
@retry(
|
|
88
|
+
max_attempts=5,
|
|
89
|
+
delay=1.0,
|
|
90
|
+
exceptions=(ConnectionError, TimeoutError) # Only retry these
|
|
91
|
+
)
|
|
92
|
+
def connect():
|
|
93
|
+
...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Non-matching exceptions (e.g., `ValueError`) propagate immediately without retrying.
|
|
97
|
+
|
|
98
|
+
### 4. Lifecycle Hooks
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from retryflow import retry, RetryContext
|
|
102
|
+
|
|
103
|
+
def on_retry(ctx: RetryContext):
|
|
104
|
+
print(f"Attempt {ctx.attempt}/{ctx.max_attempts} failed: {ctx.exception}")
|
|
105
|
+
print(f"Retrying in {ctx.next_wait:.2f}s...")
|
|
106
|
+
|
|
107
|
+
def on_failure(ctx: RetryContext):
|
|
108
|
+
alert_team(f"{ctx.func_name} failed after {ctx.attempts} attempts")
|
|
109
|
+
|
|
110
|
+
def on_success(ctx: RetryContext):
|
|
111
|
+
metrics.record("api.success", tags={"attempt": ctx.attempt})
|
|
112
|
+
|
|
113
|
+
@retry(
|
|
114
|
+
max_attempts=5,
|
|
115
|
+
delay=1.0,
|
|
116
|
+
on_retry=on_retry,
|
|
117
|
+
on_failure=on_failure,
|
|
118
|
+
on_success=on_success,
|
|
119
|
+
)
|
|
120
|
+
def critical_call():
|
|
121
|
+
...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 5. Async Functions
|
|
125
|
+
|
|
126
|
+
Works exactly the same — retryflow detects `async def` automatically:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
@retry(max_attempts=4, delay=0.5, timeout=3.0)
|
|
130
|
+
async def async_fetch(url):
|
|
131
|
+
async with aiohttp.ClientSession() as session:
|
|
132
|
+
async with session.get(url) as resp:
|
|
133
|
+
return await resp.json()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 6. Reusable Config Object
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from retryflow import retry, RetryConfig
|
|
140
|
+
|
|
141
|
+
# Define once, apply everywhere
|
|
142
|
+
production_retry = RetryConfig(
|
|
143
|
+
max_attempts=5,
|
|
144
|
+
delay=1.0,
|
|
145
|
+
backoff=2.0,
|
|
146
|
+
jitter=0.3,
|
|
147
|
+
timeout=10.0,
|
|
148
|
+
exceptions=(ConnectionError, TimeoutError),
|
|
149
|
+
log_retries=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@retry(config=production_retry)
|
|
153
|
+
def service_a(): ...
|
|
154
|
+
|
|
155
|
+
@retry(config=production_retry)
|
|
156
|
+
def service_b(): ...
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 7. Context Manager
|
|
160
|
+
|
|
161
|
+
For one-off retries without decorating a function:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from retryflow import attempt
|
|
165
|
+
|
|
166
|
+
with attempt(max_attempts=3, delay=1.0, timeout=5.0) as r:
|
|
167
|
+
data = r.run(fetch, "https://api.example.com/data")
|
|
168
|
+
|
|
169
|
+
print(f"Completed in {r.elapsed:.2f}s")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## API Reference
|
|
175
|
+
|
|
176
|
+
### `@retry` decorator
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
retry(
|
|
180
|
+
func=None, # The function to wrap (when used without parentheses)
|
|
181
|
+
*,
|
|
182
|
+
max_attempts=3, # Total attempts including the first call
|
|
183
|
+
delay=1.0, # Base delay in seconds between attempts
|
|
184
|
+
backoff=2.0, # Delay multiplier (1.0=constant, 2.0=exponential)
|
|
185
|
+
jitter=0.0, # Random seconds added to each wait (0 to jitter)
|
|
186
|
+
timeout=None, # Max seconds per attempt (None = no limit)
|
|
187
|
+
exceptions=(Exception,),# Only retry on these exception types
|
|
188
|
+
on_retry=None, # Callable(RetryContext) called before each retry
|
|
189
|
+
on_failure=None, # Callable(RetryContext) called when all attempts fail
|
|
190
|
+
on_success=None, # Callable(RetryContext) called on success
|
|
191
|
+
log_retries=True, # Emit warning logs on each retry
|
|
192
|
+
config=None, # RetryConfig object (overrides all other params)
|
|
193
|
+
)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `RetryConfig`
|
|
197
|
+
|
|
198
|
+
All the same parameters as `@retry`, packaged into a reusable object.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
cfg = RetryConfig(max_attempts=5, delay=1.0, backoff=2.0, timeout=10.0)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `RetryContext`
|
|
205
|
+
|
|
206
|
+
Passed to hook callbacks with information about the current state.
|
|
207
|
+
|
|
208
|
+
| Attribute | Type | Description |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `attempt` | `int` | Current attempt number (1-indexed) |
|
|
211
|
+
| `max_attempts` | `int` | Total configured attempts |
|
|
212
|
+
| `exception` | `Exception` | The exception that just occurred |
|
|
213
|
+
| `elapsed` | `float` | Total elapsed seconds since first attempt |
|
|
214
|
+
| `next_wait` | `float \| None` | Seconds until next retry (None on final attempt) |
|
|
215
|
+
| `func_name` | `str` | Name of the decorated function |
|
|
216
|
+
|
|
217
|
+
### `RetryError`
|
|
218
|
+
|
|
219
|
+
Raised when all attempts are exhausted.
|
|
220
|
+
|
|
221
|
+
| Attribute | Type | Description |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `last_exception` | `Exception` | The final exception raised |
|
|
224
|
+
| `attempts` | `int` | Total number of attempts made |
|
|
225
|
+
|
|
226
|
+
### `attempt` context manager
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
with attempt(max_attempts=3, delay=1.0, ...) as r:
|
|
230
|
+
result = r.run(func, *args, **kwargs)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
After the `with` block, `r.result`, `r.elapsed`, `r.last_error`, and `r.total_attempts` are available.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Backoff Formula
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
wait = delay * (backoff ^ (attempt - 1)) + random(0, jitter)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
| attempt | delay=1, backoff=2 | delay=0.5, backoff=3 |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| 1st retry | 1.0s | 0.5s |
|
|
246
|
+
| 2nd retry | 2.0s | 1.5s |
|
|
247
|
+
| 3rd retry | 4.0s | 4.5s |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Running Tests
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
pip install pytest pytest-asyncio
|
|
255
|
+
pytest tests/ -v
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT © prabhay759
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "retryflow"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Smart retry engine with exponential backoff, jitter, per-exception rules, timeout, and async support"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "prabhay759" }]
|
|
13
|
+
keywords = ["retry", "backoff", "resilience", "async", "timeout", "decorator"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.21"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/prabhay759/retryflow"
|
|
34
|
+
Repository = "https://github.com/prabhay759/retryflow"
|
|
35
|
+
Issues = "https://github.com/prabhay759/retryflow/issues"
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
retryflow — Smart retry engine with exponential backoff, jitter,
|
|
3
|
+
per-exception rules, timeout support, and async compatibility.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .core import retry, RetryConfig, RetryError, RetryContext
|
|
7
|
+
from .context import attempt
|
|
8
|
+
|
|
9
|
+
__all__ = ["retry", "RetryConfig", "RetryError", "RetryContext", "attempt"]
|
|
10
|
+
__version__ = "1.0.0"
|
|
11
|
+
__author__ = "prabhay759"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
retryflow.context
|
|
3
|
+
-----------------
|
|
4
|
+
Context manager interface for retryflow.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
with attempt(max_attempts=3, delay=1) as r:
|
|
8
|
+
result = r.run(some_function, arg1, arg2)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Optional, Tuple, Type
|
|
13
|
+
|
|
14
|
+
from .core import RetryConfig, RetryContext, RetryError, _sync_retry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class attempt:
|
|
18
|
+
"""
|
|
19
|
+
Context manager that executes a callable with retry logic.
|
|
20
|
+
|
|
21
|
+
Example
|
|
22
|
+
-------
|
|
23
|
+
>>> with attempt(max_attempts=3, delay=0.5, timeout=5) as r:
|
|
24
|
+
... data = r.run(fetch_data, url)
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
Same as RetryConfig / @retry decorator.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
max_attempts: int = 3,
|
|
34
|
+
delay: float = 1.0,
|
|
35
|
+
backoff: float = 2.0,
|
|
36
|
+
jitter: float = 0.0,
|
|
37
|
+
timeout: Optional[float] = None,
|
|
38
|
+
exceptions: Tuple[Type[Exception], ...] = (Exception,),
|
|
39
|
+
on_retry=None,
|
|
40
|
+
on_failure=None,
|
|
41
|
+
on_success=None,
|
|
42
|
+
log_retries: bool = True,
|
|
43
|
+
config: Optional[RetryConfig] = None,
|
|
44
|
+
):
|
|
45
|
+
self._cfg = config or RetryConfig(
|
|
46
|
+
max_attempts=max_attempts,
|
|
47
|
+
delay=delay,
|
|
48
|
+
backoff=backoff,
|
|
49
|
+
jitter=jitter,
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
exceptions=exceptions,
|
|
52
|
+
on_retry=on_retry,
|
|
53
|
+
on_failure=on_failure,
|
|
54
|
+
on_success=on_success,
|
|
55
|
+
log_retries=log_retries,
|
|
56
|
+
)
|
|
57
|
+
self.result: Any = None
|
|
58
|
+
self.last_error: Optional[Exception] = None
|
|
59
|
+
self.total_attempts: int = 0
|
|
60
|
+
self.elapsed: float = 0.0
|
|
61
|
+
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
66
|
+
return False # Never suppress exceptions from the with-block body
|
|
67
|
+
|
|
68
|
+
def run(self, func: Callable, *args, **kwargs) -> Any:
|
|
69
|
+
"""
|
|
70
|
+
Execute func(*args, **kwargs) with the configured retry policy.
|
|
71
|
+
|
|
72
|
+
Returns the function's return value, or raises RetryError if
|
|
73
|
+
all attempts are exhausted.
|
|
74
|
+
"""
|
|
75
|
+
start = time.monotonic()
|
|
76
|
+
try:
|
|
77
|
+
self.result = _sync_retry(func, self._cfg, args, kwargs)
|
|
78
|
+
except RetryError as e:
|
|
79
|
+
self.last_error = e.last_exception
|
|
80
|
+
self.total_attempts = e.attempts
|
|
81
|
+
self.elapsed = time.monotonic() - start
|
|
82
|
+
raise
|
|
83
|
+
self.elapsed = time.monotonic() - start
|
|
84
|
+
return self.result
|