Dev.to · 16 min read

Designing Fault-Tolerant Autonomous AI Agents: Circuit Breakers, Retry Policies, and Observability

Designing Fault-Tolerant Autonomous AI Agents: Circuit Breakers, Retry Policies, and Observability

Originally published on tamiz.pro. From Circuit Breakers to Agent Resilience: Designing Fault-Tolerant Systems for Autonomous AI Workflows 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. Table of Contents 1. The Unreliability Paradox in Agentic Systems 2. Core Resilience Patterns for LLM Workflows 3. Implementing a Semantic Circuit Breaker 4. Retry Strategies and Context Preservation 5. Observability: Tracing Semantic Decisions 6. Production Best Practices and Edge Cases 7. Frequently Asked Questions 1. The Unreliability Paradox in Agentic Systems 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. 2. Core Resilience Patterns for LLM Workflows To design a fault-tolerant autonomous agent, we must map traditional patterns to the LLM context: The Circuit Breaker Pattern (Reimagined) 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. Retry with Exponential Backoff and Jitter 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. The Bulkhead Pattern 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. 3. Implementing a Semantic Circuit Breaker A semantic circuit breaker monitors the "quality" of LLM outputs, not just their availability. It tracks metrics like: JSON validity rate (for tool-calling agents). Constraint adherence (e.g., did the LLM stay within the allowed tools?). Latency percentiles. 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. 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 = 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 Why Semantic Metrics Matter 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". 4. Retry Strategies and Context Preservation 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. Context-Aware Retry Logic We introduce a RetryWithContextModification pattern. Instead of blindly retrying, the orchestrator analyzes the error and modifies the prompt for the next attempt. 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") Key Insights: Transient vs. Permanent: Distinguish between network timeouts (Transient) and Schema/Logic errors (Permanent). Context Pollution: When a semantic failure occurs, inject a specific warning into the context (as shown in the code) rather than just retrying. This leverages the LLM's ability to self-correct when explicitly told what went wrong. Exponential Backoff: Essential for rate limits, but add jitter to prevent "thundering herd" scenarios if multiple agents are running. 5. Observability: Tracing Semantic Decisions Standard OpenTelemetry traces are insufficient for agents. You need to know why the LLM made a specific tool call. This requires Semantic Tracing. The Span Hierarchy Workflow Span: The high-level goal (e.g., "Book Trip"). Agent Span: The orchestration loop iteration. LLM Span: The specific model call. Include prompt_tokens, completion_tokens, and model_id. Tool Span: The external API call. Metrics to Expose In addition to standard latency/error rates, expose: Decision Entropy: How confident the LLM was in its choice? (Use logprobs if available). Context Growth Rate: How fast is the context window filling up? This is a leading indicator of potential failures in long-running agents. Fallback Frequency: How often does the system trigger the circuit breaker fallback? Implementing a simple tracer: 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)) 6. Production Best Practices and Edge Cases 1. The "Infinite Loop" Risk 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. Solution: Maintain a 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 why it is repeating itself, or explicitly break the loop. 2. Token Budgeting as a Resource Limit Treat tokens as a hard resource limit like memory or CPU. Solution: Implement a 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. 3. Fallback Hierarchy Define a clear fallback strategy: Retry: (Cheap, fast) Try again with backoff. Model Downgrade: (Medium cost) If the complex model fails, try a smaller/faster model for this specific step. Heuristic Shortcut: (No LLM cost) If the LLM is flaky, use a hardcoded rule-based path for that specific tool. Human-in-the-Loop: (High latency, High trust) Pause the agent and request human approval. 4. Idempotency 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. Solution: Generate a request_id for each tool call and ensure downstream APIs support idempotency keys. Store the request_id in the agent's state so that on retry, it can deduplicate. 7. Frequently Asked Questions 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). The Retry Policy That Doesn't Spiral Naive retries fail because they retry everything. A well-designed retry policy classifies failures: Transient network blips → retry immediately with exponential backoff Rate limits → respect Retry-After, back off aggressively Tool validation errors → fix and retry once, then escalate LLM hallucination/confidence drops → retry with a corrected prompt, not a blind retry 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. Circuit Breaker Implementation The circuit breaker wraps your retry policy. It has three states: Closed: Requests flow normally. Failures increment a counter. Open: Requests fail fast. After a timeout, transition to half-open. Half-Open: Allow a limited number of test requests. If they succeed, close. If they fail, open again. 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 Observability: Making the Invisible Visible An agent without observability is a black box. You need three signals: 1. State Transitions Log every circuit breaker state change. This tells you when your system is degrading. 2. Decision Tracing For every tool call, record: What the agent decided to do Why it made that decision (the reasoning) What the outcome was Whether it matched expectations 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}") 3. Token Budget Monitoring Track token consumption per goal. If an agent is burning through tokens without progress, it's stuck in a loop. Putting It All Together Here's a complete agent loop that integrates circuit breaking, retry policies, and observability: 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()) Recovery Patterns When an agent crashes and restarts, it should: Load the last saved state from disk Check the circuit breaker state — if it was open, wait for timeout Re-evaluate pending errors — some may have resolved themselves Resume from the last successful tool result 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 Conclusion 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: Circuit breakers prevent cascading failures by failing fast Retry policies handle transient issues without human intervention Observability turns failures into learning opportunities 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News