Module 7: Reliability Patterns & Production Checklist
3. Retry with Exponential Backoff
Description
Retry without a strategy is worse than no retry: if OpenAI is saturated and your app sends 100 simultaneous requests that fail, retrying them immediately only makes the problem worse. Exponential backoff + jitter is the strategy that turns the "thundering herd" problem into a controlled distribution of retries. In this capsule you'll implement complete retry with tenacity, including logging callbacks, support for the Retry-After header, and tests that verify the behavior without depending on real delays.
The naive retry problem
# ❌ Immediate retry — the anti-pattern
def call_llm_naive(prompt: str) -> str:
for attempt in range(3):
try:
return client.chat.completions.create(...)
except RateLimitError:
if attempt < 2:
continue # ← IMMEDIATE retry
raise
# Scenario: 50 users make requests at the same time
# OpenAI returns 429 for all of them
# They all do an IMMEDIATE retry
# OpenAI receives 150 requests (50 × 3) in milliseconds
# They all fail with 429 again
# The retries make the problem worse
# Result: all users see error 500
# ─────────────────────────────────────────────────────────────
# ✅ Exponential backoff + jitter — the solution
# Scenario: 50 users make requests at the same time
# They all receive 429
# Retry 1: wait 1.0s + random(0, 0.5)s → different moments
# Retry 2: wait 2.0s + random(0, 1.0)s → distributed
# Retry 3: wait 4.0s + random(0, 2.0)s → well distributed
# The 50 retries spread out over ~10 seconds instead of milliseconds
# OpenAI processes gradually, most complete
tenacity: the retry library
pip install tenacity
# The building blocks of tenacity
from tenacity import (
retry, # Main decorator
stop_after_attempt, # Maximum N attempts
stop_after_delay, # Maximum N total seconds
wait_exponential, # Exponential wait: 1s, 2s, 4s, 8s...
wait_random_exponential, # Exponential + jitter: 1.2s, 2.7s, 4.1s...
wait_fixed, # Fixed wait: always N seconds
retry_if_exception_type, # Retry only for certain exception types
retry_if_exception, # Retry if the function returns True for the error
before_sleep, # Callback BEFORE sleeping (logging)
after, # Callback AFTER each attempt
RetryError, # Exception if all attempts fail
)
Basic configuration with tenacity
# src/infrastructure/retry_config.py
from openai import APITimeoutError, RateLimitError, APIConnectionError, InternalServerError
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception
def is_retryable_openai_error(exception: Exception) -> bool:
"""
Determine whether an OpenAI exception is worth retrying.
Transients → retry:
- APITimeoutError: the model took long, it may work again
- RateLimitError: the limit frees up over time
- APIConnectionError: the network failed momentarily
- InternalServerError (5xx): server error, it may recover
Permanent → no retry:
- AuthenticationError (401): the key is wrong, retry doesn't help
- BadRequestError (400): the input is wrong, retry will give the same error
- PermissionDeniedError (403): no access, retry doesn't help
"""
from openai import AuthenticationError, BadRequestError, PermissionDeniedError
# No retry for permanent errors
if isinstance(exception, (AuthenticationError, BadRequestError, PermissionDeniedError)):
return False
# Retry for known transients
if isinstance(exception, (APITimeoutError, RateLimitError,
APIConnectionError, InternalServerError)):
return True
# For LLMProviderError, use the integrated classification
from src.infrastructure.llm_provider import LLMProviderError
if isinstance(exception, LLMProviderError):
return exception.should_retry
# For unknown errors, no retry by default (safe)
return False
# Standard configuration for most AI apps
STANDARD_RETRY = retry(
# Stop after 4 attempts (1 original + 3 retries)
stop=stop_after_attempt(4),
# Exponential backoff with jitter:
# Attempt 1: 1s base + random(0, 1)s
# Attempt 2: 2s base + random(0, 1)s
# Attempt 3: 4s base + random(0, 2)s
# Maximum 30s wait
wait=wait_random_exponential(multiplier=1, min=1, max=30),
# Only retry transient errors
retry=retry_if_exception(is_retryable_openai_error),
# If all attempts fail, propagate the original exception
reraise=True
)
# More aggressive configuration for critical operations
AGGRESSIVE_RETRY = retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=2, min=2, max=60),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
# Light configuration for low-priority operations
LIGHT_RETRY = retry(
stop=stop_after_attempt(2), # Only 1 retry
wait=wait_random_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
RetryProvider: wrapping the LLMProvider
# src/infrastructure/retry_provider.py
import structlog
from tenacity import retry, RetryError, before_sleep_log
from src.infrastructure.llm_provider import LLMProvider, LLMProviderError
from src.infrastructure.retry_config import is_retryable_openai_error, STANDARD_RETRY
log = structlog.get_logger()
class RetryProvider:
"""
Wrapper that adds retry with exponential backoff to any LLMProvider.
Implements the LLMProvider Protocol, so it's completely
transparent to the code that uses it.
"""
def __init__(
self,
inner: LLMProvider,
max_attempts: int = 4,
min_wait_seconds: float = 1.0,
max_wait_seconds: float = 30.0
):
self._inner = inner
self._max_attempts = max_attempts
self._min_wait = min_wait_seconds
self._max_wait = max_wait_seconds
self._attempt_count = 0 # For metrics
self._retry_count = 0
def complete(self, messages: list[dict], **kwargs) -> str:
"""
Call the inner provider with automatic retry for transient errors.
"""
self._attempt_count += 1
attempt_number = 0
last_error = None
# Create the retry decorator dynamically with the configured parameters
from tenacity import (
retry as tenacity_retry, stop_after_attempt,
wait_random_exponential, retry_if_exception
)
@tenacity_retry(
stop=stop_after_attempt(self._max_attempts),
wait=wait_random_exponential(
multiplier=1,
min=self._min_wait,
max=self._max_wait
),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True,
before_sleep=self._log_retry_attempt
)
def _call_with_retry():
return self._inner.complete(messages, **kwargs)
return _call_with_retry()
def _log_retry_attempt(self, retry_state) -> None:
"""Callback that runs before each sleep between retries."""
self._retry_count += 1
log.warning(
"llm_retry_attempt",
attempt_number=retry_state.attempt_number,
exception_type=type(retry_state.outcome.exception()).__name__,
sleep_seconds=round(retry_state.next_action.sleep, 1),
total_retries=self._retry_count
)
@property
def total_retries(self) -> int:
"""Total retries performed (useful for metrics)."""
return self._retry_count
Using tenacity as a decorator directly
# If you prefer a decorator instead of a wrapper class:
from openai import APITimeoutError, RateLimitError, APIConnectionError
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
import structlog
log = structlog.get_logger()
def log_retry(retry_state):
log.warning(
"llm_retry",
attempt=retry_state.attempt_number,
error=str(retry_state.outcome.exception())[:100],
wait_seconds=round(getattr(retry_state.next_action, 'sleep', 0), 1)
)
@retry(
stop=stop_after_attempt(4),
wait=wait_random_exponential(multiplier=1, min=1, max=30),
retry=retry_if_exception_type((APITimeoutError, RateLimitError, APIConnectionError)),
reraise=True,
before_sleep=log_retry
)
def call_openai_with_retry(client, messages: list[dict], model: str,
temperature: float, max_tokens: int) -> str:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens
)
return response.choices[0].message.content
The jitter effect visualized
# Why jitter? Visualizing the problem:
# WITHOUT jitter — thundering herd:
# t=0: 50 requests fail
# t=1s: 50 simultaneous retries fail
# t=2s: 50 simultaneous retries fail
# t=4s: 50 simultaneous retries fail
# All retries collide at the same moments
# WITH jitter — natural distribution:
# t=0: 50 requests fail
# t=1.0s: 3 retries
# t=1.1s: 7 retries
# t=1.3s: 12 retries
# t=1.6s: 15 retries
# t=2.0s: 8 retries
# t=2.1s: 5 retries
# The retries spread out, reducing pressure on the API
import random
import time
def exponential_backoff_with_jitter(attempt: int, base: float = 1.0, max_wait: float = 30.0) -> float:
"""Calculate the wait time with jitter."""
exponential = base * (2 ** attempt) # 1, 2, 4, 8, 16...
with_cap = min(exponential, max_wait) # No more than max_wait
with_jitter = with_cap * (0.5 + random.random() * 0.5) # ±50% random
return with_jitter
# Example of generated times for 3 retries:
for attempt in range(3):
wait_time = exponential_backoff_with_jitter(attempt)
print(f"Attempt {attempt+1}: wait {wait_time:.2f}s")
# Attempt 1: wait 0.73s
# Attempt 2: wait 1.87s
# Attempt 3: wait 3.14s
# (different each time due to the random)
Respecting the Retry-After header
# When OpenAI returns 429, it sometimes includes Retry-After
# We should respect that time instead of our backoff
from tenacity import wait_base
import time
class WaitWithRetryAfter(wait_base):
"""
Custom wait strategy for tenacity that respects the Retry-After header.
If the error has a retry_after, it waits that time.
Otherwise, it uses exponential backoff with jitter.
"""
def __init__(self, multiplier: float = 1.0, min_wait: float = 1.0, max_wait: float = 60.0):
self._multiplier = multiplier
self._min = min_wait
self._max = max_wait
def __call__(self, retry_state) -> float:
exc = retry_state.outcome.exception()
# Try to get the Retry-After from the error
if hasattr(exc, "retry_after") and exc.retry_after:
return min(exc.retry_after, self._max)
# Also try in LLMProviderError
from src.infrastructure.llm_provider import LLMProviderError
if isinstance(exc, LLMProviderError) and exc.retry_after:
return min(exc.retry_after, self._max)
# Fallback to exponential backoff
attempt = retry_state.attempt_number - 1 # 0-indexed
exponential = self._multiplier * (2 ** attempt)
with_cap = min(exponential, self._max)
return max(self._min, with_cap * (0.5 + 0.5 * __import__("random").random()))
# Usage:
@retry(
stop=stop_after_attempt(4),
wait=WaitWithRetryAfter(multiplier=1.0, min_wait=1.0, max_wait=60.0),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
def call_with_retry_after_support(messages):
return client.chat.completions.create(...)
Retry tests
# tests/unit/test_retry_provider.py
import pytest
from unittest.mock import MagicMock, patch
from openai import APITimeoutError, AuthenticationError
from src.infrastructure.retry_provider import RetryProvider
from src.infrastructure.mock_provider import MockProvider
from src.infrastructure.llm_provider import LLMProviderError
from src.infrastructure.error_classifier import ErrorCategory
class TestRetryProvider:
def test_succeeds_on_first_attempt_without_retry(self):
"""If the first call works, there are no retries."""
inner = MockProvider('{"sentiment": "positive", "score": 0.8, "confidence": 0.9}')
retry_provider = RetryProvider(inner, max_attempts=3)
result = retry_provider.complete([{"role": "user", "content": "test"}])
assert '{"sentiment"' in result
assert inner.call_count == 1
assert retry_provider.total_retries == 0
def test_retries_on_transient_error_then_succeeds(self):
"""If it fails 2 times and then works, it must have 3 calls."""
transient_error = LLMProviderError(
"Timeout",
category=ErrorCategory.TRANSIENT,
should_retry=True
)
inner = MockProvider(
responses=["error", "error", '{"sentiment": "positive", "score": 0.8, "confidence": 0.9}'],
raise_error=None
)
call_count = 0
original_complete = inner.complete
def tracked_complete(messages, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
raise transient_error
return original_complete(messages, **kwargs)
inner.complete = tracked_complete
retry_provider = RetryProvider(inner, max_attempts=4, min_wait_seconds=0.01, max_wait_seconds=0.1)
result = retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3
def test_reraises_after_max_attempts(self):
"""If it fails max_attempts times, it must propagate the exception."""
error = LLMProviderError("Rate limit", category=ErrorCategory.TRANSIENT, should_retry=True)
call_count = 0
def failing_complete(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = failing_complete
retry_provider = RetryProvider(inner, max_attempts=3, min_wait_seconds=0.01, max_wait_seconds=0.1)
with pytest.raises(LLMProviderError):
retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3 # Exactly max_attempts attempts
def test_does_not_retry_non_retryable_error(self):
"""For non-retryable errors, it only tries once."""
error = LLMProviderError(
"Invalid API key",
category=ErrorCategory.AUTH_ERROR,
should_retry=False
)
call_count = 0
def auth_failing(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = auth_failing
retry_provider = RetryProvider(inner, max_attempts=3, min_wait_seconds=0.01, max_wait_seconds=0.1)
with pytest.raises(LLMProviderError):
retry_provider.complete([{"role": "user", "content": "test"}])
assert call_count == 1 # Only 1 attempt — no retry for auth errors
Exercises
Exercise 1: Calculate wait times
For a configuration with wait_exponential(multiplier=1, min=1, max=30), what would the wait times between retries be? (without jitter)
See solution
- Retry 1: min(1 × 2^0, 30) = min(1, 30) = 1s
- Retry 2: min(1 × 2^1, 30) = min(2, 30) = 2s
- Retry 3: min(1 × 2^2, 30) = min(4, 30) = 4s
- Retry 4: min(1 × 2^3, 30) = min(8, 30) = 8s
- Retry 5: min(1 × 2^4, 30) = min(16, 30) = 16s
- Retry 6+: min(1 × 2^5, 30) = min(32, 30) = 30s (capped)
Total if all fail: 1+2+4+8 = 15s minimum across 4 attempts
Exercise 2: Add logging to the retry
Modify the tenacity configuration so it logs each attempt with: attempt number, error type, and wait seconds:
See solution
import structlog
log = structlog.get_logger()
def log_before_retry(retry_state):
log.warning(
"llm_retry_attempt",
attempt=retry_state.attempt_number,
error_type=type(retry_state.outcome.exception()).__name__,
sleep_seconds=round(getattr(retry_state.next_action, 'sleep', 0), 1)
)
@retry(
stop=stop_after_attempt(4),
wait=wait_random_exponential(min=1, max=30),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True,
before_sleep=log_before_retry # ← callback
)
def my_llm_call(messages):
...
Exercise 3: Configure retry for different scenarios
Write the tenacity configuration for these two cases:
- Payment operation — if it fails, the user loses the transaction. You need to be aggressive with retries.
- Cache preload — if it fails, nothing serious happens. It can retry once and move on.
See solution
# 1. Payment operation (aggressive)
PAYMENT_RETRY = retry(
stop=stop_after_attempt(5),
wait=wait_random_exponential(multiplier=2, min=2, max=60),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
# 2. Cache preload (light)
CACHE_RETRY = retry(
stop=stop_after_attempt(2),
wait=wait_random_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception(is_retryable_openai_error),
reraise=True
)
The key difference: more attempts + longer wait for critical operations. Fewer attempts + short wait for operations that don't block the user.
Exercise 4: Write a retry test without real delays
Write a test that verifies that RetryProvider makes exactly 3 attempts when the inner provider fails with a transient error, without waiting for the real delays:
See solution
def test_retries_exactly_max_attempts():
"""Use low min_wait and max_wait so the tests are fast."""
call_count = 0
error = LLMProviderError(
"Timeout", category=ErrorCategory.TRANSIENT, should_retry=True
)
def always_fail(messages, **kwargs):
nonlocal call_count
call_count += 1
raise error
inner = MockProvider()
inner.complete = always_fail
# min_wait=0.01 and max_wait=0.01 eliminate the real delays
provider = RetryProvider(inner, max_attempts=3,
min_wait_seconds=0.01, max_wait_seconds=0.01)
with pytest.raises(LLMProviderError):
provider.complete([{"role": "user", "content": "test"}])
assert call_count == 3
The trick is to use min_wait_seconds=0.01 so the tests run in milliseconds instead of seconds.
Troubleshooting
"My retries take too long in tests"
Set min_wait_seconds=0.01 and max_wait_seconds=0.1 in your tests. Never use the production values (1-30s) in unit tests — they would make your suite take minutes.
"tenacity doesn't retry my error"
Verify that your is_retryable_openai_error function returns True for that error. The most common mistake is passing a wrapped exception: if RetryProvider receives an LLMProviderError but is_retryable_openai_error only checks OpenAI's exceptions directly, it won't match. Make sure it also checks LLMProviderError.should_retry.
"I get RetryError instead of the original exception"
If you use reraise=False (tenacity's default), the exception you get is tenacity.RetryError, not the original one. Always use reraise=True to propagate the original exception after exhausting the attempts.
"How do I know if the retry is working in production?"
Look for the llm_retry_attempt event in your logs. If you never see it, either your system works well and there are no transient errors, or your before_sleep callback isn't configured. If you see it too often, your API may have a persistent problem and you need a circuit breaker (capsule 04).
Summary
- Retry without backoff is harmful: it aggravates the rate limiting problem
- Exponential backoff: each attempt waits twice as long as the previous
- Jitter: adds randomness to avoid the thundering herd
- Only transient errors: no retry for auth errors, bad requests, etc.
- Retry-After: if the API indicates when to retry, respect it
- RetryProvider wrapper: transparent to the domain thanks to M6's DI
- 3-4 attempts: more attempts = more latency perceived by the user
Additional resources
- tenacity Documentation — The complete library
- Exponential Backoff and Jitter (AWS) — The canonical article on jitter
- OpenAI Rate Limits — Official retry guide
- Thundering Herd Problem — The problem that jitter solves