Skip to content

Fix: Tenacity Not Working — Retries Not Firing, Exception Filters, and Async Support

FixDevs ·

Quick Answer

How to fix Tenacity errors — retry decorator not retrying, stop_after_attempt vs stop_after_delay, retry_if_exception_type filter, async retry decorator, jitter for backoff, and RetryError unwrap original exception.

The Error

You decorate a function with @retry and it doesn’t actually retry on failure:

from tenacity import retry

@retry
def flaky():
    raise ValueError("fail")

flaky()
# Just raises ValueError immediately — no retry

Or retries fire on the wrong exception types:

@retry
def fetch():
    raise FileNotFoundError("data.csv")
    # Retries forever on a permanent error

Or async functions don’t get retried:

@retry(stop=stop_after_attempt(3))
async def call_api():
    response = await httpx.get("...")
    response.raise_for_status()

asyncio.run(call_api())
# Raises immediately, never retries — needs the async variant

Or you can’t extract the original error from RetryError:

try:
    flaky()
except RetryError as e:
    print(e)
    # RetryError[<Future at 0x... state=finished raised ValueError>]
    # But what was the ValueError message?

Or backoff is too aggressive and hammers the API:

@retry(wait=wait_fixed(0))
def hit_api():
    raise ConnectionError()
# Retries thousands of times per second, banned in 30 seconds

Tenacity is the universal Python retry library — used by httpx wrappers, OpenAI SDK retries, Celery internal retries, every API client that needs backoff. The defaults are deliberately conservative (retry indefinitely on any exception), which causes its own set of problems. This guide covers the common patterns and pitfalls.

Why This Happens

Tenacity’s @retry decorator without arguments uses defaults that surprise newcomers: it retries on any exception forever, with no delay. This is rarely what you want. Production code needs explicit stop=, wait=, and retry= conditions.

The async support requires either the same @retry decorator (Tenacity 8.x+ detects async automatically) or the explicit @retry with proper handling. Mixing sync and async retry decorators on the wrong function type produces silent failures.

Fix 1: Default Behavior — Why Your Retry “Didn’t Work”

from tenacity import retry

@retry
def flaky():
    raise ValueError("fail")

flaky()

This does retry — but forever, with no delay. The function appears to “do nothing” because it’s stuck in an infinite retry loop. You hit Ctrl+C and see the trace.

Always specify stop and wait:

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(5),                # Max 5 attempts
    wait=wait_exponential(multiplier=1, min=1, max=30),
)
def flaky():
    raise ValueError("fail")

try:
    flaky()
except RetryError:
    print("All retries exhausted")

Common Mistake: Decorating with @retry (no args) and assuming Tenacity has reasonable defaults. It doesn’t — the defaults are “retry forever on any exception with no delay.” Always add stop= to bound the retry count or time.

Fix 2: Stop Conditions

from tenacity import (
    retry, stop_after_attempt, stop_after_delay,
    stop_when_event_set, stop_never,
)
import threading

# Stop after N attempts
@retry(stop=stop_after_attempt(5))
def f(): ...

# Stop after total time elapsed
@retry(stop=stop_after_delay(60))   # 60 seconds total
def g(): ...

# Combine stop conditions (whichever fires first)
@retry(stop=stop_after_attempt(10) | stop_after_delay(60))
def h(): ...

# Stop when an event is set (e.g., cancellation signal)
stop_event = threading.Event()
@retry(stop=stop_when_event_set(stop_event))
def i(): ...

Composed stops| means OR:

# Stop if either 10 attempts OR 5 minutes have passed
@retry(stop=(stop_after_attempt(10) | stop_after_delay(300)))
def fn(): ...

stop_never (default if no stop= is given) — retry forever. Only sensible for actually-recoverable transient errors with proper backoff.

Pro Tip: Always pair stop_after_attempt with stop_after_delay. Attempt count alone doesn’t bound time (if wait grows exponentially, 10 attempts could take hours). Delay alone doesn’t bound attempts (a fast-failing function could retry hundreds of times). Combining both gives upper bounds on both axes.

Fix 3: Wait Strategies

from tenacity import (
    retry, stop_after_attempt,
    wait_fixed, wait_random, wait_exponential, wait_chain,
)

# Fixed delay
@retry(stop=stop_after_attempt(5), wait=wait_fixed(2))   # 2s between retries
def f(): ...

# Random delay
@retry(stop=stop_after_attempt(5), wait=wait_random(min=1, max=5))
def g(): ...

# Exponential backoff
@retry(
    stop=stop_after_attempt(8),
    wait=wait_exponential(multiplier=1, min=1, max=60),
    # 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped), 60s
)
def h(): ...

# Exponential with jitter (avoid thundering herd)
from tenacity import wait_random_exponential
@retry(stop=stop_after_attempt(5), wait=wait_random_exponential(multiplier=1, max=60))
def i(): ...

# Combined strategy
@retry(wait=wait_chain(*[wait_fixed(1)] * 3 + [wait_exponential(min=2)]))
# First 3 retries: 1s each; then exponential
def j(): ...

Wait strategy comparison:

StrategyPatternUse for
wait_fixed(n)n, n, n, n…Constant interval polling
wait_random(a, b)Random in [a, b]Avoiding sync herds
wait_exponential(...)1, 2, 4, 8, 16…API rate limit recovery
wait_random_exponential(...)Jittered exponentialProduction API clients
wait_chain(*ws)Sequence of strategiesCustom multi-phase backoff

wait_random_exponential is the standard for API clients — exponential growth bounded by max, plus jitter to avoid thundering herd when many clients fail and retry simultaneously.

Fix 4: Retry on Specific Exceptions

from tenacity import retry, retry_if_exception_type, stop_after_attempt
import requests

@retry(
    stop=stop_after_attempt(5),
    retry=retry_if_exception_type(requests.ConnectionError),
)
def fetch(url):
    return requests.get(url, timeout=5).json()

Now only ConnectionError triggers retry; ValueError, KeyError, etc. propagate immediately.

Multiple exception types:

@retry(
    retry=retry_if_exception_type((ConnectionError, TimeoutError, requests.HTTPError)),
    stop=stop_after_attempt(5),
)
def fetch(): ...

Retry on HTTP status code:

import requests
from tenacity import retry, retry_if_exception, stop_after_attempt

def is_retryable_http_error(exception):
    return (
        isinstance(exception, requests.HTTPError)
        and exception.response.status_code in (429, 500, 502, 503, 504)
    )

@retry(
    stop=stop_after_attempt(5),
    retry=retry_if_exception(is_retryable_http_error),
    wait=wait_exponential(multiplier=2, max=60),
)
def fetch():
    response = requests.get("...")
    response.raise_for_status()
    return response.json()

Retry on result (no exception, just a value indicating retry):

from tenacity import retry, retry_if_result, stop_after_attempt

@retry(
    stop=stop_after_attempt(10),
    retry=retry_if_result(lambda result: result is None),
    wait=wait_fixed(1),
)
def poll_for_value():
    value = check_external_system()
    return value   # Returns None until ready, then real value

result = poll_for_value()

Combine conditions (OR):

from tenacity import retry_if_exception_type, retry_if_result

@retry(
    retry=(retry_if_exception_type(ConnectionError) | retry_if_result(lambda r: r is None)),
    stop=stop_after_attempt(10),
)
def fn(): ...

Common Mistake: Using bare retry=retry_if_exception_type(Exception) to “retry on all exceptions” — this retries on KeyboardInterrupt, SystemExit, syntax errors caught at runtime, and AssertionErrors from your test framework. Always specify the actual exception classes you want to recover from.

Fix 5: Async Support

from tenacity import retry, stop_after_attempt, wait_exponential
import httpx
import asyncio

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(min=1, max=30),
)
async def call_api():   # Tenacity 8.x+ detects async automatically
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/data")
        response.raise_for_status()
        return response.json()

asyncio.run(call_api())

For older Tenacity versions (< 8.0), use the explicit AsyncRetrying:

from tenacity import AsyncRetrying, stop_after_attempt, RetryError

async def call_api():
    try:
        async for attempt in AsyncRetrying(
            stop=stop_after_attempt(5),
            wait=wait_exponential(min=1, max=30),
            reraise=True,
        ):
            with attempt:
                async with httpx.AsyncClient() as client:
                    response = await client.get("...")
                    response.raise_for_status()
                    return response.json()
    except RetryError:
        return None

For httpx-specific patterns that benefit from tenacity retries, see httpx not working.

Async generators and streaming — wrap the call that establishes the connection, not the iteration:

@retry(stop=stop_after_attempt(3))
async def open_stream():
    response = await client.stream("GET", "https://example.com/sse")
    response.raise_for_status()
    return response

async def consume():
    response = await open_stream()
    async for chunk in response.aiter_bytes():
        process(chunk)
    # Don't retry the entire generator — that would re-open + re-iterate

Fix 6: Extracting the Original Exception from RetryError

from tenacity import retry, stop_after_attempt, RetryError

@retry(stop=stop_after_attempt(3))
def fail():
    raise ValueError("specific message")

try:
    fail()
except RetryError as e:
    print(e)
    # RetryError[<Future at ... raised ValueError>]

The actual ValueError is hidden inside RetryError. Extract it:

try:
    fail()
except RetryError as e:
    original = e.last_attempt.exception()
    print(type(original))     # <class 'ValueError'>
    print(str(original))       # 'specific message'
    raise original from e      # Re-raise with original type and message

reraise=True — simpler pattern, propagates the original exception directly:

@retry(stop=stop_after_attempt(3), reraise=True)
def fail():
    raise ValueError("specific")

try:
    fail()
except ValueError as e:
    print(e)   # 'specific' — original exception, not wrapped

Pro Tip: Use reraise=True by default in production code. Callers see the actual exception type — ValueError, ConnectionError, whatever — instead of always handling RetryError. This makes error handling at the call site cleaner and respects exception type expectations.

Fix 7: Callback Hooks for Observability

from tenacity import retry, stop_after_attempt, before_log, after_log, before_sleep_log
import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

@retry(
    stop=stop_after_attempt(5),
    before=before_log(logger, logging.INFO),
    after=after_log(logger, logging.WARNING),
    before_sleep=before_sleep_log(logger, logging.INFO),
)
def fetch():
    ...

Output for a 3-attempt run:

INFO: Starting call to 'fetch', this is the 1st time calling it.
WARNING: Finished call to 'fetch' after 0.123(s), this was the 1st time calling it.
INFO: Retrying fetch in 1.0 seconds as it raised ConnectionError: ...
INFO: Starting call to 'fetch', this is the 2nd time calling it.
...

Custom callback functions:

from tenacity import retry, stop_after_attempt, RetryCallState

def log_attempt(retry_state: RetryCallState):
    fn_name = retry_state.fn.__name__
    attempt = retry_state.attempt_number
    print(f"Attempt {attempt} for {fn_name}")
    if retry_state.outcome and retry_state.outcome.failed:
        print(f"  Exception: {retry_state.outcome.exception()}")

@retry(
    stop=stop_after_attempt(5),
    before=log_attempt,
)
def fetch(): ...

Metrics integration (Prometheus, StatsD, etc.):

from prometheus_client import Counter

retry_counter = Counter("api_retries_total", "API retries", ["endpoint", "status"])

def track_retry(state):
    endpoint = state.kwargs.get("endpoint", "unknown")
    status = "failed" if state.outcome.failed else "success"
    retry_counter.labels(endpoint=endpoint, status=status).inc()

@retry(stop=stop_after_attempt(5), before_sleep=track_retry)
def call(endpoint): ...

Fix 8: Using Retrying Directly (No Decorator)

For dynamic retry logic, use Retrying as a context-managed object:

from tenacity import Retrying, stop_after_attempt, wait_fixed, RetryError

def make_request(url, max_retries):
    retryer = Retrying(
        stop=stop_after_attempt(max_retries),
        wait=wait_fixed(1),
        reraise=True,
    )
    try:
        return retryer(do_request, url)
    except Exception as e:
        return {"error": str(e)}

Iterator pattern:

from tenacity import Retrying, stop_after_attempt

for attempt in Retrying(stop=stop_after_attempt(5), reraise=True):
    with attempt:
        # Code inside `with attempt:` is retried
        response = httpx.get("...")
        response.raise_for_status()
        result = response.json()
        break   # Exit the for loop on success

This pattern is especially useful when:

  • Retry conditions depend on runtime values
  • You want explicit control over what’s retried vs not
  • You’re inside a function where decoration isn’t practical

tenacity.nap for sleep control:

from tenacity import Retrying, stop_after_attempt, wait_fixed

# Make sleeps interruptible via threading.Event
import threading
stop_event = threading.Event()

retryer = Retrying(
    stop=stop_after_attempt(10),
    wait=wait_fixed(5),
    sleep=lambda seconds: stop_event.wait(seconds),
)

# In another thread/signal handler:
# stop_event.set()  → wakes up the sleep early

Still Not Working?

Tenacity vs Other Retry Libraries

  • Tenacity — Most popular, rich API, async support. Default choice.
  • Retry (retry package) — Simpler decorator, fewer features. Use for quick scripts.
  • backoff — Different API, integrates well with async, also widely used.
  • Built-in requests retries via urllib3.util.retry.Retry — limited but no extra deps.

For most production code, Tenacity is worth the dependency.

Common Wait Strategy Defaults

For HTTP API clients, a sensible default:

@retry(
    stop=(stop_after_attempt(5) | stop_after_delay(60)),
    wait=wait_random_exponential(multiplier=1, max=30),
    retry=retry_if_exception_type((ConnectionError, TimeoutError, HTTPError)),
    reraise=True,
)
def api_call(): ...

Bounded by both attempts and time, jittered exponential backoff, only retries on transient errors, re-raises the original exception.

Testing Functions with Retries

Mock the time/sleep to avoid actual waits in tests:

import pytest
from tenacity import retry, stop_after_attempt, wait_fixed
from unittest.mock import patch

@retry(stop=stop_after_attempt(3), wait=wait_fixed(5))
def fetch():
    raise ConnectionError()

def test_retries_three_times():
    with patch("tenacity.nap.time.sleep") as mock_sleep:
        with pytest.raises(Exception):
            fetch()
        assert mock_sleep.call_count == 2   # Slept twice between 3 attempts

Or use a tiny wait in tests:

import os
RETRY_WAIT = 0.001 if os.getenv("TESTING") else 1

@retry(wait=wait_fixed(RETRY_WAIT))
def fn(): ...

For pytest fixture patterns with retry mocking, see pytest fixture not found. For Loguru-based logging of retry attempts that pairs well with Tenacity’s before_sleep hooks, see Loguru not working.

Combining with httpx and Async APIs

import httpx
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type

@retry(
    stop=stop_after_attempt(5),
    wait=wait_random_exponential(multiplier=1, max=30),
    retry=retry_if_exception_type((httpx.ConnectError, httpx.ReadTimeout)),
    reraise=True,
)
async def fetch(client: httpx.AsyncClient, url: str):
    response = await client.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

async with httpx.AsyncClient() as client:
    data = await fetch(client, "https://api.example.com/users")

For httpx timeout configuration that interacts with retry logic, see httpx not working. For OpenAI API integration where retries are essential due to rate limits, see OpenAI API not working.

Don’t Retry Idempotency-Breaking Operations

Some operations aren’t safe to retry:

  • POST that creates resources (might create duplicates)
  • Payment transactions
  • Side-effects with no transaction boundary

For these, retry only on specific errors that guarantee the operation didn’t take effect (e.g., ConnectionError before sending the request body) or use idempotency keys.

import uuid

idempotency_key = str(uuid.uuid4())

@retry(stop=stop_after_attempt(5))
def create_payment(amount):
    return requests.post(
        "https://api.example.com/payments",
        headers={"Idempotency-Key": idempotency_key},
        json={"amount": amount},
    )

The server uses the key to deduplicate — first request creates, retries return the same result without creating duplicates.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles