A retry library that never hides your errors.
Project description
stubbornly
A retry library that never hides your errors.
Zero config for the common case. Tells you exactly what's happening by default. Never buries the real exception.
pip install stubbornly
30-second example
from stubbornly import retry
@retry
def call_flaky_api():
response = requests.get("https://example.com/api")
response.raise_for_status()
return response.json()
That's it. By default, stubbornly will:
- Retry up to 3 times on any
Exception - Wait 1s, then 2s between retries (exponential backoff)
- Log every attempt to the
stubbornlylogger:
INFO stubbornly call_flaky_api :: [stubbornly] attempt 1/3 failed: HTTPError: 503 Service Unavailable. retrying in 1.00s
INFO stubbornly call_flaky_api :: [stubbornly] attempt 2/3 failed: HTTPError: 503 Service Unavailable. retrying in 2.00s
- Raise
StubbornlyGaveUpwith the real original exception always accessible on.original_exception— never buried.
The same decorator works on async functions, no configuration needed:
@retry
async def async_flaky_api():
async with aiohttp.ClientSession() as session:
async with session.get("https://example.com/api") as r:
r.raise_for_status()
return await r.json()
Configuration
All parameters are optional. The zero-config default is production-usable.
from stubbornly import retry
from stubbornly.backoff import exponential, jitter
@retry(
attempts=5, # total attempts (default: 3)
wait="exponential", # "fixed", "exponential", a number, or a callable
on=(ConnectionError, TimeoutError), # which exceptions to retry (default: Exception)
on_retry=my_callback, # custom callback instead of default logging
idempotent=False, # warn if this non-idempotent fn is retried
)
def robust_call():
...
wait= options
| Value | Behaviour |
|---|---|
0 or 0.0 |
No sleep (useful in tests) |
| A positive number | Fixed wait in seconds |
"fixed" |
Fixed 1s wait |
"exponential" |
1s, 2s, 4s, ... capped at 60s |
A callable (attempt: int) -> float |
Full control |
Custom backoff
from stubbornly.backoff import exponential, jitter
# Exponential with jitter: attempt 1 → ~1s±25%, attempt 2 → ~2s±25%, ...
@retry(wait=jitter(exponential(base=1.0), ratio=0.25))
def f():
...
# Custom: 0.5s, 1.5s, 4.5s (base=0.5, multiplier=3)
@retry(wait=exponential(base=0.5, multiplier=3.0))
def g():
...
Handling the final failure
StubbornlyGaveUp always carries:
.original_exception— the real last exception, never wrapped or hidden.attempts— total attempts made.history— list of(attempt_number, exception)tuples for every failure
from stubbornly import retry, StubbornlyGaveUp
@retry(attempts=3, wait=0)
def flaky():
raise ConnectionError("gateway timeout")
try:
flaky()
except StubbornlyGaveUp as e:
print(e.original_exception) # ConnectionError: gateway timeout
print(e.attempts) # 3
print(e.history) # [(1, ConnectionError(...)), (2, ...), (3, ...)]
Custom logging / callback
Provide on_retry= to replace the default logger:
def my_handler(attempt: int, total: int, exc: BaseException, wait: float, func) -> None:
my_observability_system.record_retry(
func=func.__name__,
attempt=attempt,
total=total,
error=str(exc),
wait_seconds=wait,
)
@retry(on_retry=my_handler)
def f():
...
idempotent=False guard
Marks a function as non-idempotent. If it's retried, a UserWarning is emitted — a reminder that retrying may cause double-writes, double-charges, or other duplicate side-effects.
@retry(attempts=3, idempotent=False)
def charge_payment(amount):
payment_gateway.charge(amount) # NOT safe to retry blindly
This is a feature tenacity still doesn't have.
Testing
Zero-wait mode
Pass wait=0 to skip all sleep in tests without monkeypatching:
@retry(attempts=3, wait=0, on=(ConnectionError,))
def my_func():
...
Disable retries entirely
Use stubbornly.testing.disable() to turn off all retry logic inside a test — the first exception propagates immediately, and you get the original exception type (not StubbornlyGaveUp):
import stubbornly.testing
def test_underlying_failure():
with stubbornly.testing.disable():
with pytest.raises(ConnectionError):
my_func() # raises on first attempt, original error, no retries
Guaranteed behaviour
These are invariants, not defaults. They cannot be overridden by configuration:
KeyboardInterrupt,SystemExit, andasyncio.CancelledErrorare never retriedasyncio.CancelledErroris never swallowed in async functions (not slept through)StubbornlyGaveUpalways carries.original_exception
Why not tenacity?
stubbornly fixes 7 real, documented tenacity pain points:
| # | Problem | tenacity issue | stubbornly fix |
|---|---|---|---|
| 1 | No visibility — what's retrying, why? | #644 | Attempt-level logging on by default |
| 2 | Original error buried/wrapped on final failure | #521 | StubbornlyGaveUp.original_exception always present |
| 3 | Stacking decorators breaks / wrong exceptions | #534 | Single RetryPolicy, explicit composition |
| 4 | Async: cancellation gets swallowed | #529 | CancelledError hard-excluded, always propagates |
| 5 | Can't easily test retry code | #228 | Built-in wait=0 + testing.disable() |
| 6 | Retrying non-idempotent ops causes silent double-execution | #504 | idempotent=False warning guard |
| 7 | Steep config curve for the common case | General sentiment | Zero-config @retry is production-usable |
Requirements
- Python 3.9+
- Zero runtime dependencies (stdlib only)
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stubbornly-1.0.0.tar.gz.
File metadata
- Download URL: stubbornly-1.0.0.tar.gz
- Upload date:
- Size: 23.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cbae70b50dde80f169ad8c90199008d5c41335b8230f365e8f6a7a03956ead1d
|
|
| MD5 |
67ab41eabc9213bf693e5a5b5e64f0c4
|
|
| BLAKE2b-256 |
be207845ccad795502dbab1d7f38cadd4f79a08c85b53c7d5408605ce9758354
|
File details
Details for the file stubbornly-1.0.0-py3-none-any.whl.
File metadata
- Download URL: stubbornly-1.0.0-py3-none-any.whl
- Upload date:
- Size: 15.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31f2e109fc4ad381fb4e2c0d603f0d178056509aebfdb5c2e7c0ecd483cee1c3
|
|
| MD5 |
304f7ce91b12df6449fdae65551094d4
|
|
| BLAKE2b-256 |
b39aff08ffcae01e040f2abb039ffe234b1ce7833f5ffd31f179d39fff36659e
|