Skip to main content

tenaz

PyPI version Python versions License CI Downloads

Production-grade retry with built-in circuit breaker for Python. Exponential backoff · Full jitter · Sync + Async · ~660 lines · Zero dependencies.

from tenaz import retry

@retry(max_attempts=5, backoff=0.5, jitter=True)
def call_api():
    return requests.get("https://api.example.com/data")

One decorator. One file. Everything you need to make failing calls resilient — without pulling in a framework to do it.


Why tenaz?

Every retry library makes the same tradeoffs wrong:

tenaz tenacity backoff retrying
Exponential backoff ✓ ✓ ✓ ✓
Full jitter (AWS-style) ✓ ✓ ✓ ✗
Circuit breaker built-in ✓ ✗ ✗ ✗
Sync + async ONE decorator ✓ ✓ partial ✗
Per-exception strategy ✓ ✓ ✓ ✗
Retry on return value ✓ ✓ ✗ ✗
Total timeout cap ✓ ✓ ✗ ✗
Context manager mode ✓ ✗ ✗ ✗
Async context manager ✓ ✗ ✗ ✗
Lifecycle hooks ✓ partial partial ✗
Dependencies 0 0 0 0
Lines of code ~660 800+ 400+ 300+
Maintained (2025+) ✓ ✓ ✓ ✗ abandoned

tenacity is the industry standard — and it's good. But its API is confusing (retry_if_exception_type vs retry_if_not_exception_type vs retry_if_exception_message...), it has no circuit breaker, and you'll need pybreaker or circuitbreaker as a separate package.

tenaz gives you retry + circuit breaker in one decorator with an API you can memorize in 60 seconds.


Install

pip install tenaz

Or just copy tenaz.py into your project. It's one file.

Requires: Python 3.10+
Dependencies: None (stdlib only)


Quick Start

Basic retry

from tenaz import retry

@retry(max_attempts=3, backoff=1.0)
def fetch_data():
    return requests.get("https://api.example.com")

Fails → waits ~1s → retries → waits ~2s → retries → gives up.

@retry(max_attempts=5, backoff=0.5, jitter=True)
def fetch_data():
    ...

Full jitter randomizes delay between [0, calculated_delay]. This is the AWS-recommended strategy to prevent thundering herd.

Async — same decorator

@retry(max_attempts=3, backoff=0.5)
async def async_fetch():
    async with aiohttp.ClientSession() as session:
        return await session.get("https://api.example.com")

No separate import. No @async_retry. The same @retry detects async def and does the right thing.


Core Features

Selective retry by exception type

@retry(
    retry_on=(ConnectionError, TimeoutError),   # Retry these
    abort_on=(AuthenticationError, ValueError),  # Never retry these
)
def smart_call():
    ...

abort_on overrides retry_on — if an AuthenticationError happens, it raises immediately. No retries. No wasted time.

Retry on return value

@retry(
    max_attempts=10,
    backoff=2.0,
    retry_on_result=lambda r: r is None,  # Retry while result is None
)
def poll_job_status(job_id: str):
    return api.get_status(job_id)  # Returns None until job completes

When retry_on_result returns True, the call is treated as a failure and retried. The on_retry hook receives None as the exception argument for result-based retries.

Total timeout

@retry(max_attempts=100, backoff=1.0, total_timeout=30.0)
def bounded_retry():
    return flaky_service.call()

Caps the total wall-clock time across all attempts. Raises RetryTimeout when exceeded. Sleep delays are automatically clamped to the remaining budget — no sleeping past the deadline.

from tenaz import RetryTimeout

try:
    bounded_retry()
except RetryTimeout as e:
    print(e.elapsed)         # 30.1
    print(e.attempts)        # 12
    print(e.last_exception)  # ConnectionError(...)

Circuit breaker

@retry(
    max_attempts=3,
    circuit_threshold=5,    # Open after 5 consecutive failures
    circuit_timeout=30.0,   # Stay open for 30 seconds
)
def db_query():
    return database.execute("SELECT ...")

After 5 consecutive failures across any number of calls, the circuit opens. All subsequent calls raise CircuitOpen instantly — no execution, no waiting. After 30 seconds, the circuit enters half-open state and allows one test call through.

This prevents cascading failures when a downstream service is dead. No need for a separate pybreaker package.

from tenaz import CircuitOpen

try:
    db_query()
except CircuitOpen as e:
    # e.until = estimated monotonic timestamp when the breaker may allow a call.
    # In OPEN: when timeout expires. In HALF_OPEN: when a probe failure would reopen.
    return cached_response()

Lifecycle hooks

@retry(
    max_attempts=5,
    on_retry=lambda attempt, exc, delay: (
        logger.warning(f"Attempt {attempt} failed: {exc}. Retrying in {delay:.1f}s")
    ),
    on_fail=lambda exc, total: (
        alerting.send(f"CRITICAL: Failed after {total} attempts: {exc}")
    ),
    on_circuit_open=lambda: (
        metrics.increment("circuit_breaker.opened")
    ),
)
def critical_operation():
    ...
Hook Signature When
on_retry (attempt: int, exception | None, delay: float) Before each retry sleep. exception is None for result-based retries.
on_fail (last_exception, total_attempts: int) When all attempts exhausted
on_circuit_open () When circuit breaker trips

Max delay cap

@retry(max_attempts=10, backoff=2.0, max_delay=30.0)
def long_retry():
    ...

Delays: 2s → 4s → 8s → 16s → 30s → 30s → 30s → ...

Without max_delay, attempt 10 would wait 1024 seconds. The cap prevents absurd waits.

Context manager mode

When you can't use a decorator — inline retry with a per-attempt context manager:

from tenaz import retrying

for attempt in retrying(max_attempts=3, backoff=0.5):
    with attempt:
        connection = establish_connection()

Each attempt is a context manager that catches retryable exceptions and suppresses them. If all attempts fail, the iterator raises RetryExhausted, chained from the last exception.

Async context manager

from tenaz import async_retrying

async for attempt in async_retrying(max_attempts=3, backoff=0.5):
    with attempt:
        result = await fragile_async_call()

RetryExhausted exception

When all attempts fail, RetryExhausted wraps the last exception:

from tenaz import retry, RetryExhausted

@retry(max_attempts=3)
def will_fail():
    raise ConnectionError("down")

try:
    will_fail()
except RetryExhausted as e:
    print(e.last_exception)  # ConnectionError("down")
    print(e.attempts)         # 3

API Reference

retry(**kwargs)

The main decorator. All parameters are optional.

@retry(
    max_attempts=3,          # Total attempts including first (1 = no retry)
    backoff=1.0,             # Base delay in seconds
    max_delay=60.0,          # Maximum delay cap
    jitter=True,             # Full jitter (randomize 0 to delay)
    retry_on=(Exception,),   # Exception types to retry
    abort_on=(),             # Exception types to abort immediately
    retry_on_result=None,    # Predicate fn(result) → bool; True = retry
    on_retry=None,           # Hook: fn(attempt, exception | None, delay)
    on_fail=None,            # Hook: fn(last_exception, total_attempts)
    total_timeout=0.0,       # Wall-clock cap in seconds (0 = unlimited)
    circuit_threshold=0,     # Failures to trip breaker (0 = disabled)
    circuit_timeout=30.0,    # Seconds breaker stays open
    on_circuit_open=None,    # Hook: fn() called when breaker trips
)
def your_function():
    ...

Parameter Validation

All parameters are validated at construction time. Invalid values raise ValueError:

  • max_attempts must be >= 1
  • backoff must be >= 0
  • max_delay must be >= 0
  • total_timeout must be >= 0
  • circuit_threshold must be >= 0
  • circuit_timeout must be > 0 when circuit is enabled

retrying(**kwargs) / async_retrying(**kwargs)

Iterables for inline retry. Each iteration yields an _Attempt context manager. On final failure, raises RetryExhausted.

# Sync
for attempt in retrying(
    max_attempts=3,
    backoff=1.0,
    max_delay=60.0,
    jitter=True,
    retry_on=(Exception,),
    abort_on=(),              # exception types that abort immediately
    total_timeout=0.0,        # wall-clock cap (0 = unlimited)
    on_retry=None,            # hook: fn(attempt, exception, delay)
):
    with attempt:
        result = do_something()

# Async
async for attempt in async_retrying(max_attempts=3):
    with attempt:
        result = await do_something()

Exceptions

Exception When Attributes
RetryExhausted All attempts consumed .last_exception, .attempts
RetryTimeout Total timeout exceeded .last_exception, .elapsed, .attempts
CircuitOpen Circuit breaker is open .until (monotonic timestamp)

Backoff Strategy Deep Dive

tenaz uses exponential backoff with full jitter, the same strategy recommended by AWS for distributed systems.

delay = min(backoff × 2^attempt, max_delay)
if jitter:
    delay = random(0, delay)

Why full jitter? Without jitter, when a service recovers from an outage, all clients retry at the exact same intervals — creating synchronized bursts that can take the service down again. Full jitter spreads retries uniformly across the delay window.

Without jitter (thundering herd):
  Client A: ──X──1s──X──2s──X──4s──✓
  Client B: ──X──1s──X──2s──X──4s──✓    ← all hit at same time
  Client C: ──X──1s──X──2s──X──4s──✓

With full jitter (spread):
  Client A: ──X──0.7s──X──1.3s──X──2.8s──✓
  Client B: ──X──0.2s──X──1.9s──X──0.5s──✓   ← distributed
  Client C: ──X──0.9s──X──0.4s──X──3.1s──✓

To disable jitter (not recommended in production):

@retry(jitter=False)

Circuit Breaker Deep Dive

The circuit breaker has 3 states:

 ┌─────────────────────────────────────────────────────────┐
 │                                                         │
 │   CLOSED ──(N failures)──→ OPEN ──(timeout)──→ HALF-OPEN
 │     ↑                                            │
 │     └──────────(success)─────────────────────────┘
 │                                            │
 │                              (failure)──→ OPEN
 └─────────────────────────────────────────────────────────┘
State Behavior
Closed Normal operation. Failures counted.
Open All calls rejected instantly with CircuitOpen. No execution.
Half-Open Exactly one probe call allowed (atomic). Success → Closed. Failure → Open. All other callers rejected.

The breaker is per-decorated-function, thread-safe, and shares state across all calls to that function.

# Breaker trips after 5 failures, stays open 30s
@retry(max_attempts=2, circuit_threshold=5, circuit_timeout=30.0)
def service_call():
    return requests.get("https://fragile-service.com")

# After 5 consecutive failures across any caller:
# - service_call() raises CircuitOpen instantly for 30 seconds
# - After 30s, one call goes through as a test
# - If test succeeds → circuit closes, normal operation resumes
# - If test fails → circuit reopens for another 30s

Scope: the breaker gates new calls, not the in-flight retry loop

is_open is checked once, when a decorated call starts. Within that call the max_attempts retry loop always runs to completion — even if those failures trip the breaker partway through. The breaker only fast-fails the next call.

@retry(max_attempts=100, circuit_threshold=5)
def call(): ...
# One failing call still makes all 100 attempts: the breaker opens at failure 5,
# but the loop already in flight is not interrupted. The *next* call() is the
# first to raise CircuitOpen.

Keep circuit_threshold >= max_attempts if you want the breaker to trip only between calls, so a single call's retries are never cut short mid-sequence.


Real-World Examples

HTTP client with full protection

import requests
from tenaz import retry, RetryExhausted, CircuitOpen

@retry(
    max_attempts=4,
    backoff=0.5,
    max_delay=10.0,
    retry_on=(requests.ConnectionError, requests.Timeout),
    abort_on=(requests.HTTPError,),    # Don't retry 4xx/5xx
    circuit_threshold=10,
    circuit_timeout=60.0,
    total_timeout=30.0,
    on_retry=lambda a, e, d: logger.info(f"Retry {a}, wait {d:.1f}s"),
)
def api_get(url: str) -> dict:
    resp = requests.get(url, timeout=5)
    resp.raise_for_status()
    return resp.json()

Polling with retry_on_result

@retry(
    max_attempts=20,
    backoff=1.0,
    max_delay=10.0,
    total_timeout=120.0,
    retry_on_result=lambda r: r["status"] == "pending",
)
def wait_for_deploy(deploy_id: str) -> dict:
    return api.get_deploy_status(deploy_id)

Database reconnection

@retry(
    max_attempts=5,
    backoff=2.0,
    max_delay=30.0,
    retry_on=(psycopg2.OperationalError,),
    on_fail=lambda e, n: alert(f"DB unreachable after {n} attempts"),
)
def get_user(user_id: int):
    with db.cursor() as cur:
        cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
        return cur.fetchone()

Async task queue consumer

@retry(max_attempts=3, backoff=1.0, circuit_threshold=20, circuit_timeout=120.0)
async def process_job(job_id: str):
    async with aiohttp.ClientSession() as session:
        resp = await session.post(
            "https://worker.internal/process",
            json={"job_id": job_id},
            timeout=aiohttp.ClientTimeout(total=30),
        )
        return await resp.json()

Graceful degradation with circuit breaker

from tenaz import retry, CircuitOpen, RetryExhausted

@retry(max_attempts=2, circuit_threshold=5, circuit_timeout=30.0)
def get_recommendations(user_id: str) -> list:
    return recommendation_service.fetch(user_id)

def handle_request(user_id: str):
    try:
        recs = get_recommendations(user_id)
    except CircuitOpen:
        recs = get_cached_recommendations(user_id)  # Fallback
    except RetryExhausted:
        recs = default_recommendations()             # Default
    return render(recs)

Inline retry with context manager

from tenaz import retrying

for attempt in retrying(max_attempts=3, backoff=1.0, retry_on=(IOError,)):
    with attempt:
        with open("/mnt/nfs/data.csv") as f:
            data = f.read()

Context manager with timeout and hooks

from tenaz import retrying, RetryExhausted

try:
    for attempt in retrying(
        max_attempts=10,
        backoff=0.5,
        total_timeout=30.0,
        abort_on=(PermissionError,),
        on_retry=lambda a, e, d: logger.info(f"Retry {a}, wait {d:.1f}s"),
    ):
        with attempt:
            data = fetch_from_nfs()
except RetryExhausted as e:
    logger.error(f"Failed after {e.attempts} attempts: {e.last_exception}")

Thread Safety

tenaz is thread-safe:

  • The circuit breaker uses threading.Lock for all state mutations
  • The half-open state allows exactly one probe call through — concurrent callers are rejected atomically
  • Each decorated function gets its own independent breaker instance
  • Backoff delays use time.sleep (sync) or asyncio.sleep (async) — no shared timers

Safe to use in multi-threaded servers (Django, Flask with threads, etc.) and async frameworks (FastAPI, aiohttp, etc.).


FAQ

Q: Why "tenaz"?
A: Spanish for "tenacious". Because your code should be.

Q: Can I just copy the file instead of pip install?
A: Yes. That's the intended primary use. It's one file, zero deps.

Q: How is the circuit breaker different from pybreaker?
A: It's integrated into the retry decorator — no separate wrapper needed. It's simpler (3 states, threshold + timeout), which covers 95% of use cases. If you need advanced breaker features (listeners, storage backends, excluded exceptions), use pybreaker.

Q: Does it work with Django/Flask/FastAPI?
A: Yes. It's a pure Python decorator with no framework dependencies.

Q: What Python versions?
A: 3.10+ (uses Union type syntax from __future__ annotations).

Q: Is the circuit breaker per-function or global?
A: Per-function. Each @retry(circuit_threshold=...) creates its own breaker. Two different decorated functions have independent failure counts.

Q: What's the difference between RetryExhausted and RetryTimeout?
A: RetryExhausted means all max_attempts were used. RetryTimeout means total_timeout seconds elapsed before all attempts could run. Both carry .last_exception and .attempts.


License

MIT — see LICENSE.

Metadata

Release files for tenaz 2.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tenaz 2.2.0
File Size Uploaded
tenaz-2.2.0.tar.gz 19.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tenaz 2.2.0
File Interpreter ABI Platform
tenaz-2.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 33.1 kB

Release files / tenaz-2.2.0.tar.gz

Download URL tenaz-2.2.0.tar.gz
Size 19.4 kB
Tags Source
SHA-256 checksum
How to use checksums
0e7c6c6339113598d6ca2532e80941f28db5dd8ded8afc3b3cd5978c6d662a37
BLAKE2b-256 checksum
How to use checksums
4d86d4455e9cfa35460afa026730a55f85fb15d0b5eb77835232e25f9288cab1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.2

Release files / tenaz-2.2.0-py3-none-any.whl

Download URL tenaz-2.2.0-py3-none-any.whl
Size 13.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fba1392578d20242fe85ed526dc772b3ba5e0711dac06d46b67052a53f708827
BLAKE2b-256 checksum
How to use checksums
9d2968ef8e99a49d9a2e34b20a1316662849a53e5ad71ec6ced2510c84ec0670
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.2

Release history Release notifications | RSS feed

This release

2.2.0 This release

2 release files

2.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page