Skip to main content

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 stubbornly logger:
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 StubbornlyGaveUp with 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, and asyncio.CancelledError are never retried
  • asyncio.CancelledError is never swallowed in async functions (not slept through)
  • StubbornlyGaveUp always 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stubbornly-1.0.0.tar.gz (23.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stubbornly-1.0.0-py3-none-any.whl (15.7 kB view details)

Uploaded Python 3

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

Hashes for stubbornly-1.0.0.tar.gz
Algorithm Hash digest
SHA256 cbae70b50dde80f169ad8c90199008d5c41335b8230f365e8f6a7a03956ead1d
MD5 67ab41eabc9213bf693e5a5b5e64f0c4
BLAKE2b-256 be207845ccad795502dbab1d7f38cadd4f79a08c85b53c7d5408605ce9758354

See more details on using hashes here.

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

Hashes for stubbornly-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 31f2e109fc4ad381fb4e2c0d603f0d178056509aebfdb5c2e7c0ecd483cee1c3
MD5 304f7ce91b12df6449fdae65551094d4
BLAKE2b-256 b39aff08ffcae01e040f2abb039ffe234b1ce7833f5ffd31f179d39fff36659e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page