Python Tenacity Custom Retry Conditions
python tenacity custom retry conditions: Learn how to define custom retry conditions with Tenacity, including exception-based, result-based, and combined conditions fo...
When a service call fails, a simple retry with a fixed delay often isn't enough. You may need to retry only on specific exceptions, skip retries when a result indicates a permanent failure, or apply different backoff policies based on how many attempts remain. Tenacity's retry conditions let you control exactly when a retry happens. This article focuses on python tenacity custom retry conditions: how to define them, combine them, and use them in real-world scenarios.
Understanding Tenacity's Built-in Retry Conditions
Tenacity ships with a few retry conditions that cover common cases. retry_if_exception_type retries when a specific exception class is raised. retry_if_result retries based on the return value of the decorated function. retry_if_not_result does the opposite. retry_never and retry_always are self-explanatory.
These built-ins are useful, but they only handle simple predicates. For example, you might want to retry when an exception message contains a certain substring, or when a result is None only on the third attempt. The built-in conditions don't support that kind of logic directly. That's where custom conditions come in.
A custom retry condition is simply a callable that takes a RetryCallState object and returns a boolean. Tenacity calls this predicate after each attempt to decide whether to retry. The RetryCallState object carries information about the attempt number, the exception raised, the result returned, and the elapsed time.
Defining a Custom Retry Condition with retry_if_exception
The most common custom condition is one that inspects the exception beyond its type. For instance, an API might return a 429 Too Many Requests response wrapped in an HTTPError exception. You only want to retry when the status code is 429, not on other HTTP errors. Here's how to build that condition:
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity.retry import retry_base from tenacity import RetryCallState class retry_if_http_429(retry_base): def __call__(self, retry_state: "RetryCallState") -> bool: exception = retry_state.outcome.exception() if isinstance(exception, HTTPError): return exception.response.status_code == 429 return False @retry( retry=retry_if_http_429(), wait=wait_exponential(multiplier=1, min=2, max=60), stop=stop_after_attempt(5) ) def fetch_data(): # make an HTTP request pass
The retry_base class is the base for all retry conditions. Subclassing it and overriding __call__ gives you a reusable condition. Inside __call__, you access retry_state.outcome to get the result or exception from the last attempt. outcome.exception() returns the exception if one was raised, or None if the attempt succeeded. outcome.result() returns the return value.
You can also define a plain function instead of a class. Tenacity accepts any callable that takes a RetryCallState and returns a bool. For simple cases, a function is more concise:
def is_rate_limited(retry_state): exc = retry_state.outcome.exception() return isinstance(exc, HTTPError) and exc.response.status_code == 429 @retry(retry=is_rate_limited, wait=wait_random(1, 5), stop=stop_after_attempt(4)) def call_api(): pass
Using a function avoids the boilerplate of subclassing. The tradeoff is that you can't easily store configuration or reuse the logic across multiple functions without repeating the function definition. A class is better when you need to parameterize the condition, for example, to accept a set of status codes.
Retrying Based on Return Values with retry_if_result
Sometimes a function returns a value that indicates a transient failure, not an exception. For example, a database client might return None when a connection is temporarily unavailable, or a service might return an empty list when the backend is still warming up. You can write a custom condition that inspects the result using retry_state.outcome.result().
from tenacity import retry, stop_after_attempt, wait_fixed def is_empty_result(retry_state): result = retry_state.outcome.result() return result is None or (isinstance(result, list) and len(result) == 0) @retry(retry=is_empty_result, wait=wait_fixed(2), stop=stop_after_attempt(3)) def get_items(): # returns a list or None pass
This condition retries when the function returns None or an empty list. You can combine this with exception-based conditions using retry_any or retry_all, which we'll cover next.
Combining Conditions with retry_any and retry_all
Real-world retry logic often needs to retry on multiple conditions. For example, you might want to retry when a specific exception is raised OR when the result is None. Tenacity provides retry_any and retry_all to combine conditions.
from tenacity import retry, retry_any, retry_if_exception_type, stop_after_attempt, wait_fixed def is_none_result(retry_state): return retry_state.outcome.result() is None retry_condition = retry_any( retry_if_exception_type(ConnectionError), is_none_result ) @retry(retry=retry_condition, wait=wait_fixed(1), stop=stop_after_attempt(5)) def fetch_data(): pass
retry_any retries if any of the conditions returns True. retry_all requires all conditions to be True. You can nest these combinators to build complex logic. However, keep in mind that each condition receives the same RetryCallState. If an exception was raised, outcome.result() will raise an exception if you call it without checking. Similarly, if the attempt succeeded, outcome.exception() returns None. Your conditions should be defensive about which one they inspect.
A common mistake is to assume that outcome.result() is safe to call when an exception occurred. Tenacity's outcome object will re-raise the exception if you call result() on a failed attempt. Always check outcome.exception() first or wrap the call in a try/except. The built-in conditions handle this correctly, but custom conditions must be explicit.
Using Retry State to Build Context-Aware Conditions
The RetryCallState object contains more than just the outcome. It has attempt_number, outcome, start_time, and seconds_since_start. This allows you to build conditions that depend on how many attempts have already been made or how long the retry loop has been running.
For example, you might want to retry only for the first three attempts, but give up on the fourth if the error is still occurring. You can combine that with a stop condition, but sometimes the condition itself needs to be time-aware. Here's a condition that retries only if the total elapsed time is less than 30 seconds:
from tenacity import retry, stop_after_attempt, wait_fixed def retry_within_30_seconds(retry_state): return retry_state.seconds_since_start < 30 @retry(retry=retry_within_30_seconds, wait=wait_fixed(1), stop=stop_after_attempt(10)) def call_service(): pass
Similarly, you can use attempt_number to implement a condition that only retries on certain attempts, like the second and third, but not the first or fourth. This is useful when you know a failure is likely permanent after a certain number of tries, but you still want to allow a couple of quick retries.
def retry_on_attempts_2_and_3(retry_state): return retry_state.attempt_number in (2, 3)
This condition would retry only after the second and third attempts, meaning the function runs up to four times total. Such conditions are rare but demonstrate the flexibility of the retry state.
Common Pitfalls and Runtime Behavior
One of the most frequent issues with custom retry conditions is accidentally swallowing exceptions. If you call retry_state.outcome.result() on a failed attempt, Tenacity will re-raise the original exception. That can break your retry logic because the exception propagates out of the condition, causing the whole retry loop to fail immediately. Always guard against this by checking outcome.exception() first.
Another pitfall is relying on mutable state inside a condition. If your condition uses a counter or a flag that changes across calls, be aware that Tenacity may evaluate the condition multiple times per attempt, especially when combined with retry_any or retry_all. The condition should be pure and deterministic given the RetryCallState. If you need to track state, consider using a custom before_sleep hook or a wrapper class that stores state in the retry state object itself.
Also note that the retry parameter is evaluated after each attempt, including the first one. If the condition returns False on the first attempt, the function is not retried, and the result or exception is returned as if no retry decorator existed. This is correct behavior, but it means your condition must correctly identify which attempts should be retried.
When an exception is raised, Tenacity will stop retrying if the condition returns False. The original exception is then re-raised to the caller. If the condition returns True, Tenacity will wait according to the wait strategy and then call the function again. This is the standard flow.
Performance and Maintainability Considerations
Custom retry conditions add a small overhead per attempt. The overhead is negligible compared to the network call or I/O operation you're retrying. However, if your condition performs expensive checks, like parsing a large response body or querying a database, that cost is added to every attempt. Keep conditions lightweight and avoid doing heavy work inside them. If you need to inspect the response body, consider extracting the necessary information in the function itself and returning a simplified result that the condition can check cheaply.
From a maintainability perspective, custom conditions are easier to test when they are pure functions. You can call them directly with a mock RetryCallState and verify the boolean output. This is especially useful when you have complex logic that combines multiple checks. Consider writing unit tests for your conditions, just as you would for any other business logic.
Another maintainability point is to reuse conditions across multiple functions. If you have several endpoints that all need the same rate-limit retry behavior, define the condition once and pass it to each @retry decorator. This avoids duplication and ensures consistent behavior. If you later need to change the condition, you update it in one place.
A Practical Example: Retrying on Rate Limits with Exponential Backoff
Let's put everything together with a realistic scenario. You're calling a third-party API that returns a 429 status code when you exceed its rate limit. The API also occasionally returns a 503 when the service is temporarily unavailable. You want to retry on both, but with different backoff strategies: a short random wait for 429 and a longer exponential backoff for 503. Tenacity allows you to combine a custom condition with a custom wait strategy.
First, define a condition that identifies retryable status codes:
from tenacity import retry, stop_after_attempt, wait_random_exponential, RetryCallState def is_retryable_status(retry_state): exc = retry_state.outcome.exception() if isinstance(exc, HTTPError): return exc.response.status_code in (429, 503) return False
Next, define a wait strategy that uses a longer wait for 503 and a shorter one for 429. You can write a custom wait function that inspects the last exception:
def wait_for_status(retry_state): exc = retry_state.outcome.exception() if isinstance(exc, HTTPError): if exc.response.status_code == 429: return 2 + random.uniform(0, 1) # 2-3 seconds elif exc.response.status_code == 503: return 2 ** retry_state.attempt_number # exponential return 1
Then apply both to your function:
@retry( retry=is_retryable_status, wait=wait_for_status, stop=stop_after_attempt(6) ) def call_api(): response = requests.get("https://api.example.com/data") response.raise_for_status() return response.json()
This setup gives you precise control over when to retry and how long to wait. The condition checks the status code, and the wait function decides the delay based on the same exception. This pattern is common in production services that interact with rate-limited APIs.
Custom retry conditions in Tenacity are a powerful way to encode your application's specific failure semantics. By understanding how RetryCallState works and how to combine conditions, you can build retry logic that is both robust and readable. The key is to keep conditions pure, testable, and focused on the exact behavior you need.