Reliability, Retries & Circuit Breakers

9 min read Module 6 of 10 Topic 17 of 30

What you'll learn

  • Implement exponential backoff retry logic for LLM and tool API calls
  • Build a circuit breaker that protects your agent from cascading failures
  • Design fallback strategies: provider switching, graceful degradation, and cached responses
  • Add iteration guards and cost limits to prevent runaway agent loops
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Reality of Production Agent Reliability

A typical production agent makes 10-50 LLM API calls and 5-20 tool calls per task. At each of these calls:

  • The API might return a 429 (rate limited)
  • The network might timeout
  • The API might return a 500 (server error)
  • The model might return an unparseable response
  • A tool might encounter an unexpected error

Without deliberate reliability engineering, these transient failures cascade into complete task failures. With it, most of them are invisible to the user.


Retry Logic with Tenacity

# src/reliability/retry.py
from tenacity import (
    retry, stop_after_attempt, wait_exponential, wait_random_exponential,
    retry_if_exception_type, before_sleep_log, after_log
)
from openai import RateLimitError, APITimeoutError, APIConnectionError
from anthropic import APIStatusError
import logging

logger = logging.getLogger(__name__)

# Standard retry policy for LLM API calls: applied as a decorator via @llm_retry
llm_retry = retry(
    stop=stop_after_attempt(5),           # give up after 5 total attempts (1 original + 4 retries)
    # wait_random_exponential adds jitter: prevents all retrying clients hitting the API at the same moment
    wait=wait_random_exponential(multiplier=1, min=1, max=60),
    # only retry on transient errors: don't retry on 400 Bad Request (that's a bug, not a blip)
    retry=retry_if_exception_type((
        RateLimitError,       # 429, API is throttling us; backoff and retry
        APITimeoutError,      # request timed out, network issue, safe to retry
        APIConnectionError,   # couldn't reach the API, safe to retry
    )),
    before_sleep=before_sleep_log(logger, logging.WARNING),  # log each retry attempt with wait time
    reraise=True,  # after max attempts, re-raise the original exception (don't swallow it)
)

@llm_retry
async def call_openai_with_retry(messages: list, **kwargs) -> str:
    response = await openai_client.chat.completions.create(
        messages=messages,
        **kwargs,
    )
    return response.choices[0].message.content

# Separate, more aggressive policy for tool calls: cheaper to retry, shorter max wait
tool_retry = retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=0.5, min=0.5, max=5),  # max 5s wait vs 60s for LLM
    retry=retry_if_exception_type((TimeoutError, ConnectionError)),
)

@tool_retry
async def resilient_web_search(query: str) -> list[dict]:
    return await search_web(query)

Circuit Breaker Implementation

# src/reliability/circuit_breaker.py
from enum import Enum
import time
from collections import deque
from dataclasses import dataclass, field

class CircuitState(Enum):
    CLOSED = "closed"        # normal operation, all calls pass through
    OPEN = "open"            # failing fast, dependency is down, skip all calls immediately
    HALF_OPEN = "half_open"  # HALF_OPEN state: let one request through to probe if the service recovered before reopening

@dataclass
class CircuitBreaker:
    name: str
    failure_threshold: int = 5      # open the circuit after this many failures
    recovery_timeout: float = 60.0  # seconds to wait in OPEN before trying HALF_OPEN
    half_open_max_calls: int = 1    # how many probe calls allowed in HALF_OPEN before closing/reopening
    
    _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
    # deque(maxlen=10) keeps only the last 10 failure timestamps: auto-evicts older ones
    _failures: deque = field(default_factory=lambda: deque(maxlen=10), init=False)
    _last_failure_time: float = field(default=0.0, init=False)
    _half_open_calls: int = field(default=0, init=False)
    
    @property
    def state(self) -> CircuitState:
        # Auto-transition from OPEN → HALF_OPEN when recovery_timeout has elapsed
        if self._state == CircuitState.OPEN:
            if time.time() - self._last_failure_time > self.recovery_timeout:
                self._state = CircuitState.HALF_OPEN
                self._half_open_calls = 0
        return self._state
    
    def call_succeeded(self):
        # Success in any state resets the circuit: we're healthy again
        self._failures.clear()
        self._state = CircuitState.CLOSED
    
    def call_failed(self):
        self._failures.append(time.time())
        self._last_failure_time = time.time()
        
        # Once failure_threshold is reached, open the circuit to stop wasting time on a dead dependency
        if len(self._failures) >= self.failure_threshold:
            self._state = CircuitState.OPEN
            logger.warning(f"Circuit breaker '{self.name}' OPENED after {self.failure_threshold} failures")
    
    async def call(self, func, *args, **kwargs):
        state = self.state
        
        # OPEN: fail immediately without calling the dependency: saves latency and load
        if state == CircuitState.OPEN:
            raise Exception(f"Circuit '{self.name}' is OPEN, dependency is unavailable")
        
        if state == CircuitState.HALF_OPEN:
            # Allow only half_open_max_calls probes through; reject the rest while probing
            if self._half_open_calls >= self.half_open_max_calls:
                raise Exception(f"Circuit '{self.name}' is HALF_OPEN, testing recovery")
            self._half_open_calls += 1
        
        try:
            result = await func(*args, **kwargs)
            self.call_succeeded()  # probe succeeded, close the circuit
            return result
        except Exception as e:
            self.call_failed()  # probe failed, go back to OPEN
            raise

# Usage
search_circuit = CircuitBreaker(name="tavily_search", failure_threshold=5, recovery_timeout=60)

async def protected_search(query: str) -> list[dict]:
    # All calls go through the circuit breaker: it decides whether to forward or fail fast
    return await search_circuit.call(search_tavily, query)

Provider Fallback Strategy

Never depend on a single LLM provider. Implement automatic fallback:

# src/reliability/llm_fallback.py
from dataclasses import dataclass
from typing import Callable, Any

@dataclass
class LLMProvider:
    name: str
    call_func: Callable
    circuit_breaker: CircuitBreaker  # each provider gets its own circuit breaker
    is_primary: bool = True

class FallbackLLMClient:
    """LLM client with automatic provider failover."""
    
    def __init__(self, providers: list[LLMProvider]):
        # providers list is ordered: primary first, then fallbacks in preference order
        self.providers = providers
    
    async def invoke(self, messages: list, **kwargs) -> str:
        last_error = None
        
        for provider in self.providers:
            try:
                # circuit_breaker.call() will raise immediately if that provider's circuit is OPEN
                result = await provider.circuit_breaker.call(
                    provider.call_func,
                    messages,
                    **kwargs,
                )
                if not provider.is_primary:
                    # Log when falling back so you can track primary provider health in dashboards
                    logger.info(f"Used fallback provider: {provider.name}")
                return result
            
            except Exception as e:
                last_error = e
                logger.warning(f"Provider {provider.name} failed: {e}")
                continue  # try the next provider in the list
        
        # All providers failed: surface the last error for diagnostics
        raise Exception(f"All LLM providers failed. Last error: {last_error}")

# Configure with primary + fallbacks: ordered by preference
llm_client = FallbackLLMClient(providers=[
    LLMProvider(
        name="openai-gpt4o",
        call_func=call_openai,
        circuit_breaker=CircuitBreaker("openai", failure_threshold=3),
        is_primary=True,
    ),
    LLMProvider(
        name="anthropic-claude",
        call_func=call_anthropic,
        # failure_threshold=3 means 3 failures → open circuit → skip to next fallback
        circuit_breaker=CircuitBreaker("anthropic", failure_threshold=3),
        is_primary=False,
    ),
    LLMProvider(
        name="google-gemini",
        call_func=call_gemini,
        circuit_breaker=CircuitBreaker("gemini", failure_threshold=3),
        is_primary=False,
    ),
])

Iteration Limits and Cost Guards

# src/reliability/guards.py
from dataclasses import dataclass, field

@dataclass
class AgentBudget:
    max_iterations: int = 20         # hard cap on LLM calls per run
    max_cost_usd: float = 1.00       # dollar ceiling before aborting
    max_wall_seconds: float = 300.0  # 5 minutes, prevents hanging agents blocking resources
    
    # These counters are mutable state tracked at runtime: init=False means not set in __init__
    iterations: int = field(default=0, init=False)
    cost_usd: float = field(default=0.0, init=False)
    start_time: float = field(default_factory=time.time, init=False)
    
    def check(self) -> None:
        # Call this at the top of every LLM node: raises BudgetExceeded before the API call
        if self.iterations >= self.max_iterations:
            raise BudgetExceeded(f"Iteration limit ({self.max_iterations}) reached")
        
        if self.cost_usd >= self.max_cost_usd:
            raise BudgetExceeded(f"Cost limit (${self.max_cost_usd:.2f}) reached, spent ${self.cost_usd:.4f}")
        
        elapsed = time.time() - self.start_time
        if elapsed >= self.max_wall_seconds:
            raise BudgetExceeded(f"Time limit ({self.max_wall_seconds}s) reached after {elapsed:.0f}s")
    
    def record_llm_call(self, input_tokens: int, output_tokens: int, model: str = "gpt-5.6-sol"):
        self.iterations += 1
        
        # Prices are per-token (not per-million): divide by 1e6 to convert
        pricing = {
            "gpt-5.6-sol": (5.0/1e6, 15.0/1e6),      # $5/$15 per M tokens
            "gpt-5.6-luna": (0.15/1e6, 0.6/1e6),
            "claude-sonnet-5": (3.0/1e6, 15.0/1e6),
        }
        input_price, output_price = pricing.get(model, (5.0/1e6, 15.0/1e6))
        self.cost_usd += input_tokens * input_price + output_tokens * output_price

class BudgetExceeded(Exception):
    pass

# Integrate into LangGraph node
def guarded_llm_call(state: AgentState, budget: AgentBudget) -> dict:
    budget.check()  # raises BudgetExceeded before the API call if any limit is already hit
    
    response = llm.invoke(state["messages"])
    
    # response_metadata contains token counts from the provider: use them for accurate cost tracking
    if hasattr(response, 'response_metadata'):
        usage = response.response_metadata.get('token_usage', {})
        budget.record_llm_call(
            input_tokens=usage.get('prompt_tokens', 0),
            output_tokens=usage.get('completion_tokens', 0),
        )
    
    return {"messages": [response]}

Graceful Degradation

When things go wrong, degrade gracefully rather than failing completely:

async def agent_with_degradation(query: str) -> dict:
    """Agent that degrades gracefully when components fail."""
    
    # Tier 1: full agentic response with tools and multi-step reasoning
    try:
        result = await full_agent.ainvoke({"messages": [HumanMessage(content=query)]})
        return {"response": result["messages"][-1].content, "mode": "full"}
    
    except BudgetExceeded as e:
        logger.warning(f"Budget exceeded: {e}")
        # Tier 2: single LLM call without tools: still useful, just no external data
        response = llm.invoke([HumanMessage(content=query)])
        return {"response": response.content, "mode": "no_tools", "warning": str(e)}
    
    except Exception as e:
        logger.error(f"Agent failed: {e}")
        # Tier 3: last resort: always return something rather than a 500 to the user
        return {
            "response": "I encountered an issue processing your request. Please try again or contact support.",
            "mode": "error",
            "error": str(e),  # include error in response dict for logging/alerting upstream
        }

Reliability engineering isn’t glamorous, but it’s what separates demo agents from production systems. These patterns, retry, circuit breaker, fallback, budget guards, are the difference between an agent that works and one that works reliably.

Knowledge Check

3 questions to test your understanding

1 Why is it dangerous to retry an LLM API call immediately on failure without any delay?

2 What is a circuit breaker and when does it 'open'?

3 An agent has been running for 45 iterations and shows no signs of completing. What mechanisms should have prevented this?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan