Designing Fault-Tolerant Autonomous AI Agents: Circuit Breakers, Retry Policies, and Observability A developer outlined a fault-tolerant design pattern for autonomous AI agents that adapts traditional distributed-systems techniques—circuit breakers, retry with jitter, and bulkheads—to the non-deterministic failure modes of large language models. The approach introduces a "semantic circuit breaker" that trips on semantic degradation or cost explosion rather than only HTTP errors, and context-aware retries that modify context when failures stem from context-window overflow. A Python implementation of the SemanticCircuitBreaker tracks consecutive failures and opens the circuit when the failure rate exceeds a threshold within a rolling window. Originally published on tamiz.pro https://tamiz.pro/insights/fault-tolerant-autonomous-ai-agents . The era of "chatbox" AI is giving way to autonomous agents that execute multi-step workflows, call external APIs, and manage state across complex decision trees. As these systems move from prototype to production, the primary challenge shifts from model accuracy to system reliability . Unlike traditional microservices, where failure modes are predictable timeouts, 503s , autonomous agents suffer from non-deterministic failures: hallucinated tool arguments, context window exhaustion, and semantic drift. This article explores how to adapt traditional distributed systems patterns—specifically the Circuit Breaker, Retry with Jitter, and Bulkhead patterns—to the unique constraints of Large Language Model LLM interactions. In traditional software engineering, we assume that if a function receives valid inputs, it will either produce a valid output or raise a specific exception. In agentic AI, this assumption breaks down. An agent interacting with an LLM is a stochastic system wrapped in a deterministic orchestration layer. The "Unreliability Paradox" arises because we demand deterministic business outcomes e.g., "book a flight" from non-deterministic underlying components the LLM's token generation . When an LLM hallucinates a tool argument, the downstream API fails. In a standard system, we would catch the 400 error and flag it as a bug. In an agentic system, we must catch it, analyze the failure, and potentially retry the LLM with different instructions or context. Standard distributed system patterns are necessary but insufficient. A standard HTTP retry does not help if the LLM consistently generates invalid JSON for a specific complex prompt. We need a higher-level abstraction of resilience: Semantic Resilience . To design a fault-tolerant autonomous agent, we must map traditional patterns to the LLM context: A traditional circuit breaker opens when a service returns 5xx errors or times out. For LLMs, the "circuit" should trip on semantic degradation or cost explosion , not just HTTP errors. If the LLM starts generating nonsense repeatedly for a specific task type, the circuit should open to prevent wasting tokens and propagate a fallback. LLM providers have rate limits RPM/TPM . However, simple backoff is dangerous for agents. If an agent fails at step 4 of a 10-step workflow due to a transient network error, retrying step 4 is safe. If it fails due to a context window overflow, retrying without modifying the context will fail again. Therefore, retries must be context-aware . Isolate resources. If you are running multiple agents, you must isolate their LLM connections. A burst of traffic from one agent type should not starve another. This is achieved via connection pooling limits specific to agent classes. A semantic circuit breaker monitors the "quality" of LLM outputs, not just their availability. It tracks metrics like: Below is a Python implementation of a SemanticCircuitBreaker that wraps an LLM client. It tracks consecutive failures and opens the circuit if the failure rate exceeds a threshold within a rolling window. python import time from enum import Enum from dataclasses import dataclass, field from typing import Optional import asyncio class CircuitState Enum : CLOSED = "closed" OPEN = "open" HALF OPEN = "half open" @dataclass class CircuitBreakerConfig: failure threshold: int = 5 recovery timeout: float = 60.0 success threshold: int = 2 window size: int = 10 @dataclass class SemanticCircuitBreaker: config: CircuitBreakerConfig state: CircuitState = CircuitState.CLOSED failures: list float = field default factory=list last state change: float = field default factory=time.time success count: int = 0 async def execute self, func, args, kwargs : 1. Check if circuit is open if self.state == CircuitState.OPEN: if time.time - self.last state change = self.config.recovery timeout: self.state = CircuitState.HALF OPEN self.last state change = time.time Try a test call try: result = await func args, kwargs self. on success return result except Exception: self. on failure raise else: Circuit is open and recovery time hasn't passed Trigger fallback logic raise CircuitOpenError "Circuit breaker is OPEN" 2. Execute the function try: result = await func args, kwargs self. on success return result except Exception as e: self. on failure raise e def on success self : self.success count += 1 self.failures = Reset failures on success in strict mode if self.state == CircuitState.HALF OPEN: if self.success count = self.config.success threshold: self.state = CircuitState.CLOSED self.last state change = time.time elif self.state == CircuitState.CLOSED: Ensure we don't keep old data if len self.failures self.config.window size: self.failures.pop 0 def on failure self : self.success count = 0 current time = time.time self.failures.append current time Keep only recent failures within the window cutoff = current time - 30 Simple 30s window for example self.failures = t for t in self.failures if t = cutoff if self.state == CircuitState.CLOSED: Check if we hit threshold in the window recent failures = len f for f in self.failures if current time - f <= 30 if recent failures = self.config.failure threshold: self.state = CircuitState.OPEN self.last state change = current time elif self.state == CircuitState.HALF OPEN: self.state = CircuitState.OPEN self.last state change = current time class CircuitOpenError Exception : pass Note that the execute method above is a wrapper. In a real agent system, you must inject semantic validation before calling on success or on failure . For example, if the LLM returns valid JSON, it's not a "success" if the JSON contains a tool name that doesn't exist in the registry. You must parse the LLM output, validate it against your schema, and then signal success or failure to the circuit breaker. This prevents the breaker from closing too early when the LLM is "available" but "useless". Retries in agentic systems are tricky because LLM calls are stateful. If you retry a call, you are often retrying with the same context . If the failure was due to the context being too long or confusing, a simple retry will fail. We introduce a RetryWithContextModification pattern. Instead of blindly retrying, the orchestrator analyzes the error and modifies the prompt for the next attempt. python import asyncio import random class AgenticRetryPolicy: def init self, max retries=3 : self.max retries = max retries async def execute with retry self, agent step, context, error classifier : """ agent step: The function to execute LLM call context: The mutable conversation/context object error classifier: A function that returns 'TRANSIENT' or 'PERMANENT' """ for attempt in range self.max retries : try: return await agent step context except Exception as e: error type = error classifier e if error type == "TRANSIENT" or error type == "PERMANENT" and attempt < self.max retries - 1 : Calculate backoff base wait = 2 attempt jitter = random.uniform 0, 1 wait time = base wait + jitter CRITICAL: Modify context for semantic retries if error type == "PERMANENT" and "validation" in str e .lower : context.add warning f"Previous attempt failed validation. Error: {str e }. Please strictly adhere to schema." await asyncio.sleep wait time else: raise e raise Exception "Max retries exceeded" Standard OpenTelemetry traces are insufficient for agents. You need to know why the LLM made a specific tool call. This requires Semantic Tracing . prompt tokens , completion tokens , and model id . In addition to standard latency/error rates, expose: Implementing a simple tracer: python from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer = trace.get tracer "agentic system" def trace llm call model, prompt tokens, completion tokens, tool calls count : with tracer.start as current span "LLM Call" as span: span.set attribute "gen ai.system", "openai" span.set attribute "gen ai.request.model", model span.set attribute "gen ai.usage.prompt tokens", prompt tokens span.set attribute "gen ai.usage.completion tokens", completion tokens span.set attribute "agentic.tool calls.count", tool calls count Semantic attribute: Did it hallucinate? This would be determined by the validator span.set attribute "agentic.hallucination detected", False span.set status Status StatusCode.OK Agents can get stuck in a loop where they keep calling the same tool with the same arguments because the LLM doesn't recognize the previous failure. SeenArguments cache within the agent's session. If the exact same tool arguments are submitted twice in a row, force a THINK step where the LLM is asked to explain Treat tokens as a hard resource limit like memory or CPU. TokenBudgetManager . If an agent consumes 80% of its allocated budget for a sub-task, stop it and force a summary. This prevents long-running agents from burning through costs without progress. Define a clear fallback strategy: Agentic workflows are rarely idempotent. If the agent calls transfer funds , you cannot safely retry it if the first call actually succeeded but the response was lost. request id for each tool call and ensure downstream APIs support idempotency keys. Store the Q: How do I handle LLM hallucinations that look like valid tool calls? A: Always implement a Schema Validator layer between the LLM output and the actual tool execution. Never trust the LLM's self-report of what it intends to do. Parse the JSON, validate against the tool's Pydantic/Zod schema, and reject if it fails. If it fails, feed the validation error back to the LLM as a context warning. Q: Should I retry on 4xx errors from the LLM provider? A: Generally no. 400s are usually bad requests bad schema, context too long . 429s are rate limits retry with backoff . 401/403 are auth errors circuit break immediately . Treat 400s as semantic failures and adjust the prompt, not just the timing. Q: How much state should I store in the agent's memory? A: Store decisions , not just logs . Storing the entire conversation history is expensive and confusing. Instead, store a structured state object: { current goal, last tool result, pending errors, token budget remaining } . This allows the LLM to reconstruct its state without re-reading megabytes of dialogue. This is the difference between stateless retry which can spiral and stateful recovery which can learn . Naive retries fail because they retry everything . A well-designed retry policy classifies failures: Retry-After , back off aggressively python from enum import Enum import asyncio from dataclasses import dataclass class FailureType Enum : TRANSIENT = "transient" RATE LIMITED = "rate limited" VALIDATION = "validation" LLM UNCERTAIN = "llm uncertain" @dataclass class RetryPolicy: max attempts: int = 3 base delay: float = 1.0 max delay: float = 30.0 async def execute with retry self, coro func, classifier : last error = None for attempt in range self.max attempts : try: return await coro func except Exception as e: failure type = classifier e last error = e if failure type == FailureType.VALIDATION: Don't retry validation errors blindly raise delay = min self.base delay 2 attempt , self.max delay await asyncio.sleep delay raise last error The key insight: the classifier function is where your domain knowledge lives . It inspects the exception and returns the right FailureType . This is your circuit breaker's input. The circuit breaker wraps your retry policy. It has three states: python import time from enum import Enum class CircuitState Enum : CLOSED = "closed" OPEN = "open" HALF OPEN = "half open" class CircuitBreaker: def init self, failure threshold=5, timeout=60 : self.failure threshold = failure threshold self.timeout = timeout self.failure count = 0 self.last failure time = None self.state = CircuitState.CLOSED async def call self, func, args, kwargs : if self.state == CircuitState.OPEN: if time.time - self.last failure time self.timeout: self.state = CircuitState.HALF OPEN else: raise Exception "Circuit breaker is OPEN" try: result = await func args, kwargs self. on success return result except Exception as e: self. on failure raise def on success self : self.failure count = 0 self.state = CircuitState.CLOSED def on failure self : self.failure count += 1 self.last failure time = time.time if self.failure count = self.failure threshold: self.state = CircuitState.OPEN An agent without observability is a black box. You need three signals: Log every circuit breaker state change. This tells you when your system is degrading. For every tool call, record: python import logging from dataclasses import dataclass from typing import Optional @dataclass class DecisionTrace: timestamp: float goal: str action: str reasoning: str result: str confidence: float error: Optional str = None class ObservableAgent: def init self : self.traces = self.logger = logging.getLogger "agent" def record decision self, trace: DecisionTrace : self.traces.append trace self.logger.info f"Decision: {trace.action} | Confidence: {trace.confidence} | " f"Goal: {trace.goal}" if trace.error: self.logger.error f"Decision failed: {trace.error}" Track token consumption per goal. If an agent is burning through tokens without progress, it's stuck in a loop. Here's a complete agent loop that integrates circuit breaking, retry policies, and observability: python import asyncio import json from dataclasses import dataclass, field from typing import List, Dict, Any @dataclass class AgentState: current goal: str token budget: int = 10000 pending errors: List str = field default factory=list last tool result: str = "" step count: int = 0 class FaultTolerantAgent: def init self : self.circuit breaker = CircuitBreaker failure threshold=3 self.retry policy = RetryPolicy max attempts=3 self.state = None self.logger = logging.getLogger "agent" def classify error self, error: Exception - FailureType: error str = str error .lower if "rate limit" in error str or "429" in error str: return FailureType.RATE LIMITED if "validation" in error str or "invalid" in error str: return FailureType.VALIDATION if "timeout" in error str or "connection" in error str: return FailureType.TRANSIENT return FailureType.TRANSIENT async def execute tool self, tool name: str, params: Dict str, Any - str: async def call : Simulate tool execution if "fail" in tool name: raise Exception "Simulated tool failure" return f"Result from {tool name}" return await self.retry policy.execute with retry call, self.classify error async def run self, goal: str, max steps: int = 10 : self.state = AgentState current goal=goal for step in range max steps : self.state.step count = step try: Check circuit breaker before each major operation tool result = await self.circuit breaker.call self.execute tool, "some tool", {"param": "value"} self.state.last tool result = tool result self.logger.info f"Step {step}: Success" Persist state for recovery self. save state except Exception as e: error type = self.classify error e self.state.pending errors.append str e self.logger.error f"Step {step} failed: {e}" if error type == FailureType.VALIDATION: Escalate validation errors immediately raise elif self.circuit breaker.state == CircuitState.OPEN: Circuit is open, stop trying self.logger.error "Circuit breaker open, stopping agent" break return self.state def save state self : state data = { "current goal": self.state.current goal, "last tool result": self.state.last tool result, "pending errors": self.state.pending errors, "token budget remaining": self.state.token budget, "step count": self.state.step count } with open f"agent state {int time.time }.json", "w" as f: json.dump state data, f, indent=2 Usage async def main : agent = FaultTolerantAgent final state = await agent.run "Build a weather app" print f"Completed {final state.step count} steps" if name == " main ": asyncio.run main When an agent crashes and restarts, it should: php def load state self, state file: str - AgentState: with open state file, "r" as f: data = json.load f state = AgentState data Check if pending errors are still relevant state.pending errors = self. validate errors state.pending errors return state def validate errors self, errors: List str - List str : Re-check each error to see if it's still blocking valid errors = for error in errors: if self. is error resolved error : continue valid errors.append error return valid errors Fault tolerance in autonomous AI agents isn't about preventing failures — it's about making failures predictable, recoverable, and informative . The three pillars work together: The most important lesson: start simple . Begin with a basic retry loop and a state file. Add circuit breakers when you see cascading failures in production. Add sophisticated observability when you need to debug agent behavior. Over-engineering from day one creates more failure modes than it prevents. Build the minimum viable fault tolerance, then evolve it based on real failure patterns you observe. The best systems aren't designed in isolation — they're shaped by the failures they've survived.