Python Tenacity Async Retries: Retrying Coroutines
python tenacity async retries: Learn how to apply Tenacity retries to async Python functions, configure retry policies for coroutines, and handle transient failures wi...
When a coroutine fails with a transient error, the same retry logic that works for synchronous functions does not automatically translate to async code. Python's Tenacity library provides first-class support for async functions, but using it correctly requires understanding how retry policies interact with the event loop. This article explains how to apply python tenacity async retries to coroutines, configure retry conditions, and avoid common pitfalls.
Why Async Retries Need a Different Approach
Retrying an asynchronous function is not just a matter of wrapping it in a loop. In synchronous code, a retry loop blocks the thread, which is acceptable for many scripts. In asyncio, blocking the event loop prevents other tasks from progressing, so any wait between retries must yield control back to the loop. Tenacity handles this by using asyncio.sleep() internally when the decorated function is a coroutine, but only if you configure the wait strategy correctly. If you use a synchronous wait that blocks, you will stall the entire event loop.
Another difference is exception handling. A coroutine may raise an exception, but it may also be cancelled via asyncio.CancelledError. Tenacity treats cancellation as a special case and does not retry it by default, which is usually the right behavior. Understanding these distinctions helps you write retries that are both safe and effective in async applications.
Setting Up Tenacity for Async Functions
The core of Tenacity is the @retry decorator. When applied to an async function, it automatically detects the coroutine and uses async-aware internal mechanisms. The simplest usage is to decorate an async function with no arguments, which retries on any exception indefinitely. That is rarely useful, so you typically provide a stop condition and a wait strategy.
import asyncio from tenacity import retry, stop_after_attempt, wait_fixed @retry(stop=stop_after_attempt(3), wait=wait_fixed(1)) async def fetch_data(): # Simulate a flaky network call print("Attempting fetch...") raise ConnectionError("temporary failure") async def main(): try: await fetch_data() except ConnectionError: print("Failed after 3 attempts") asyncio.run(main())
This example retries fetch_data up to three times, waiting one second between attempts. The wait_fixed(1) tells Tenacity to use asyncio.sleep(1) because the function is a coroutine. If you were to use a synchronous time.sleep inside a custom wait, it would block the loop. Tenacity's built-in wait strategies are async-aware, so prefer them unless you have a specific reason to write a custom wait.
Configuring Retry Conditions for Coroutines
By default, Tenacity retries on any exception. In async code, you often want to retry only on specific exceptions that indicate transient failures, such as network timeouts or temporary service unavailability. Use retry_if_exception_type to limit retries to a tuple of exception types.
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type import aiohttp @retry( stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1, min=0.5, max=5), retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)) ) async def call_service(session, url): async with session.get(url) as response: response.raise_for_status() return await response.json()
Here, only aiohttp.ClientError and asyncio.TimeoutError trigger a retry. Other exceptions, like ValueError from a malformed response, propagate immediately. This prevents retrying on programming errors that no amount of retries will fix. The wait_exponential strategy uses exponential backoff with a multiplier of 1 second, starting at 0.5 seconds and capping at 5 seconds.
You can also combine predicates with | (or) and & (and). For example, to retry only when a specific exception is raised and a custom condition holds, you can define a predicate function. Tenacity's predicate functions can be async if they need to await something, but for simple checks a synchronous predicate works fine.
Using Wait and Stop Strategies with Async Code
Tenacity provides several built-in wait strategies: wait_fixed, wait_exponential, wait_random, and wait_chain. All of them are compatible with async functions because they internally use asyncio.sleep when the decorated function is a coroutine. The stop strategies include stop_after_attempt, stop_after_delay, and stop_all. You can combine them with | to stop when either condition is met.
from tenacity import retry, stop_after_attempt, stop_after_delay, wait_random @retry( stop=stop_after_attempt(5) | stop_after_delay(10), wait=wait_random(min=0.2, max=1.0) ) async def ping_server(): # ... pass
This retries up to five times or for ten seconds, whichever comes first, with a random wait between 0.2 and 1.0 seconds. Random wait can help avoid thundering herd problems when many clients retry simultaneously.
When writing a custom wait function for async code, you must return a number of seconds, and Tenacity will await it. You can also write an async wait function if you need to perform an async operation to determine the wait time, but that is rare. For most cases, the built-in strategies are sufficient and safer because they are tested for async compatibility.
Handling Exhausted Retries and Error Propagation
When all retry attempts are exhausted, Tenacity re-raises the last exception that occurred. This means your calling code can handle the failure as if the function had never been decorated, except that it may have taken longer to fail. You can also use reraise=True to re-raise the original exception instead of wrapping it in a RetryError. By default, Tenacity raises a RetryError that contains the last exception as its __cause__. For most async code, you probably want the original exception, so set reraise=True.
from tenacity import retry, stop_after_attempt, reraise @retry(stop=stop_after_attempt(3), reraise=True) async def flaky_operation(): raise RuntimeError("boom") async def main(): try: await flaky_operation() except RuntimeError as e: print(f"Caught: {e}")
With reraise=True, the RuntimeError propagates directly, making error handling in the caller straightforward. Without it, you would need to catch RetryError and inspect retry_error.__cause__ to get the original exception. For async code, reraise=True is often the better choice because it preserves the exception type and lets you use except clauses that match the original failure.
Avoiding Event Loop Blocking and Retry Storms
A common mistake is to use a synchronous wait function that calls time.sleep(). This blocks the entire event loop, preventing other tasks from running. Tenacity's built-in waits are safe, but if you write a custom wait, make sure it is async or at least does not block. For example, a custom wait that calls asyncio.sleep is fine, but one that calls time.sleep is not.
Another concern is retry storms. If many coroutines start retrying at the same time with fixed waits, they can overwhelm the downstream service. Use exponential backoff with jitter (randomness) to spread out retries. Tenacity's wait_random can be combined with wait_exponential using wait_chain to add jitter to each backoff step. This reduces the chance of synchronized retries.
from tenacity import retry, wait_chain, wait_fixed, wait_random @retry(wait=wait_chain(*[wait_fixed(1) + wait_random(0, 0.5) for _ in range(3)])) async def call_api(): # ... pass
This creates a chain of three waits: each is 1 second plus a random 0 to 0.5 seconds. The chain is used only for the first three attempts; after that, the last wait is reused. This pattern helps avoid bursty retries while keeping the backoff bounded.
Advanced Patterns: Custom Predicates and Async Callbacks
Tenacity allows you to define custom retry predicates that can be async. For example, you might want to retry only if the response contains a specific status code or if a health check endpoint indicates the service is recovering. You can write an async predicate that returns a boolean, and Tenacity will await it.
from tenacity import retry, stop_after_attempt, retry_if_exception async def should_retry(exception): # Simulate an async check await asyncio.sleep(0.1) return isinstance(exception, ConnectionError) and exception.args[0] != "permanent" @retry(stop=stop_after_attempt(3), retry=retry_if_exception(should_retry)) async def fetch(): raise ConnectionError("temporary")
The retry_if_exception predicate accepts a callable that takes the exception instance and returns a bool. If the callable is a coroutine function, Tenacity awaits it. This is useful when the retry decision depends on an external state that requires an async call.
You can also use the before_sleep callback to log or notify before each retry. This callback can be async if you need to await something, such as sending a metric. For example:
from tenacity import retry, stop_after_attempt, before_sleep_log import logging logging.basicConfig(level=logging.INFO) @retry(stop=stop_after_attempt(3), before_sleep=before_sleep_log(logger=logging.getLogger(__name__), log_level=logging.WARNING)) async def process(): raise ValueError("retry me")
before_sleep_log is a built-in callback that logs a warning before each retry. If you need a custom async callback, you can define an async function and pass it to before_sleep. Tenacity will await it if it is a coroutine function, making it safe to perform async operations during the retry cycle.
When combining these features, remember that the retry policy is evaluated for each attempt. If a predicate is async, it adds a small overhead to each failure, but that is usually negligible compared to the cost of the operation you are retrying. The key is to keep the predicate and callbacks lightweight to avoid slowing down the retry loop unnecessarily.
Tenacity's async support is mature, but it requires you to think about the event loop. By using the built-in wait and stop strategies, limiting retries to relevant exceptions, and avoiding blocking calls, you can build resilient async applications that handle transient failures gracefully. The patterns shown here cover the common cases and give you a foundation for more complex retry logic when your application demands it.