Back to Blog
Python

Python Tenacity: Retries, Exceptions, and Max Attempts

python tenacity retries exceptions and max attempts: Learn how to configure retries in Python with Tenacity: setting max attempts, handling exceptions, and combining s...

tenacitypythonretriesexception handlingmax attemptsbackoff
Illustration of a retry loop with a circular arrow and a counter showing attempts, representing Tenacity's retry mechanism.

python tenacity retries exceptions and max attempts requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When a network call or database operation fails, a well-placed retry can turn a transient error into a successful request. The Python Tenacity library gives you fine control over retries, exceptions, and max attempts without scattering retry logic across your codebase. This article focuses on the practical side of configuring Tenacity: how to set maximum attempts, decide which exceptions should trigger a retry, and combine stop and wait strategies for production-ready behavior.

Setting Up Tenacity

Tenacity is a Python library that wraps functions with retry logic. Install it with pip install tenacity. The most common way to use it is the @retry decorator. A minimal setup looks like this:

from tenacity import retry @retry def fetch_data(): # network call or flaky operation pass

By default, Tenacity retries indefinitely on any exception. That is rarely what you want. You almost always need to limit attempts and decide which exceptions are worth retrying. The decorator accepts parameters that control stop conditions, wait intervals, and exception filters.

Setting Maximum Attempts

The stop_after_attempt parameter limits how many times the decorated function is called, including the initial call. If the function keeps raising an exception, Tenacity will stop after the specified number of attempts and re-raise the last exception.

from tenacity import retry, stop_after_attempt @retry(stop=stop_after_attempt(3)) def fetch_data(): # will be attempted at most 3 times pass

With stop_after_attempt(3), the function runs once, and if it fails, it runs again, and again. After the third failure, Tenacity gives up and raises the exception from the final attempt. This is the direct way to enforce a maximum attempt count.

You can also stop after a time limit with stop_after_delay, but for most scenarios stop_after_attempt is the clearest way to express "try at most N times."

Controlling Which Exceptions Trigger a Retry

By default, Tenacity retries on any exception. That is often too broad. A ValueError caused by bad input will not become a success on retry, so retrying it only wastes time. The retry parameter lets you specify a condition. The most common condition is retry_if_exception_type, which accepts a tuple of exception classes.

from tenacity import retry, stop_after_attempt, retry_if_exception_type import requests @retry(stop=stop_after_attempt(3), retry=retry_if_exception_type((requests.Timeout, requests.ConnectionError))) def fetch_data(): response = requests.get("https://api.example.com/data") return response.json()

Here, only Timeout and ConnectionError trigger a retry. If the function raises a ValueError or any other exception, Tenacity immediately propagates it without retrying. This prevents masking programming errors with useless retries.

Combining Stop and Wait Strategies

Retrying immediately is rarely a good idea. If the service is overloaded, a burst of immediate retries can make the problem worse. Tenacity provides wait strategies that pause between attempts. Combine stop_after_attempt with wait_fixed or wait_exponential.

from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=1, min=2, max=30)) def fetch_data(): # wait 2s, 4s, 8s, 16s between attempts pass

wait_exponential increases the wait time between attempts. The multiplier sets the base interval, min and max clamp the delay. This is a common pattern for network calls because it gives the remote service time to recover.

If you want a constant delay, use wait_fixed(2) to wait two seconds between each attempt. The choice depends on the failure mode. For rate-limited APIs, a fixed delay might be enough. For transient network blips, exponential backoff is safer.

Handling Exceptions After Retries Are Exhausted

When Tenacity gives up, it re-raises the last exception. Your code needs to handle that outcome. The decorated function behaves like the original function, but it may take longer to raise. You can wrap the call in a try/except block as usual.

from tenacity import retry, stop_after_attempt @retry(stop=stop_after_attempt(3)) def fetch_data(): # flaky operation pass try: result = fetch_data() except Exception as e: # log the failure and fall back to a cached value result = get_cached_data()

You can also use retry_error_callback to return a fallback value instead of raising. This is useful when a degraded response is acceptable.

from tenacity import retry, stop_after_attempt, retry_error_callback def fallback(retry_state): return {"data": None, "error": "unavailable"} @retry(stop=stop_after_attempt(3), retry_error_callback=fallback) def fetch_data(): # flaky operation pass result = fetch_data() # returns fallback dict if all attempts fail

Be careful with retry_error_callback: it swallows the exception entirely. If the caller needs to know that a failure occurred, re-raising is often the better choice.

Using the Retrying Object for Dynamic Configuration

The decorator is convenient, but sometimes you need to configure retries at runtime. Tenacity provides the Retrying class, which gives you the same control without tying the logic to the function definition.

from tenacity import Retrying, stop_after_attempt, retry_if_exception_type retrying = Retrying( stop=stop_after_attempt(4), retry=retry_if_exception_type(ConnectionError), ) try: result = retrying.call(fetch_data) except ConnectionError: # handle final failure pass

This is useful when the retry policy depends on configuration, such as an environment variable that sets the max attempts. You can build the Retrying object once and reuse it for multiple calls.

Production Considerations: Backoff, Jitter, and Logging

In production, retries affect both your service and the one you are calling. Without jitter, many clients can synchronize their retries and create a thundering herd. Tenacity's wait_random_exponential adds randomness to the backoff, which helps distribute load.

from tenacity import retry, stop_after_attempt, wait_random_exponential @retry(stop=stop_after_attempt(5), wait=wait_random_exponential(multiplier=1, max=60)) def fetch_data(): # wait between 0 and 60 seconds, with exponential growth pass

Logging is also important. Tenacity emits events through Python's logging module. You can attach a logger to see when retries happen and why. This visibility helps you tune the max attempts and wait strategy without guessing.

import logging from tenacity import retry, stop_after_attempt, before_log, after_log logger = logging.getLogger(__name__) @retry( stop=stop_after_attempt(3), before=before_log(logger, logging.WARNING), after=after_log(logger, logging.INFO), ) def fetch_data(): pass

Setting a reasonable max_attempts is a balance. Too few attempts and a transient blip causes a failure. Too many attempts and you tie up resources and delay the response. A common pattern is 3 to 5 attempts with exponential backoff, but the right number depends on the service's recovery time and your latency budget. Always combine stop_after_attempt with a wait strategy to avoid hammering a struggling service.

python tenacity retries exceptions and max attempts: Practic | RYUSLOG DEV