Originally published on tamiz.pro.
Introduction
AI agents are increasingly built as stateful services that call external models, APIs, or tools. When a call fails, the naive response is to retry immediately—often in a tight loop. This "happy‑path‑only" mindset ignores the realities of distributed systems: network partitions, partial failures, and non‑idempotent side effects. The result is retry storms, resource exhaustion, and corrupted state.
This deep‑dive shows how battle‑tested distributed‑systems primitives—idempotency, circuit breakers, exponential backoff with jitter, and coordinated retries—can be applied to AI agents to make them reliable without over‑engineering.
Why AI Agents Fail
- Non‑idempotent tool calls – Writing a file, sending a message, or committing a transaction can produce different outcomes if executed twice.
- Unbounded retry loops – A failing model endpoint triggers immediate retries, consuming CPU, memory, and quota.
- Lack of back‑pressure – Agents keep pushing work onto a failing dependency, amplifying the failure.
- No visibility – Retries happen silently, making root‑cause analysis hard.
These symptoms map directly to classic distributed‑systems problems: lost messages, duplicate processing, and cascading failures.
Core Distributed Systems Principles
| Principle | What it guarantees | Typical implementation |
|---|---|---|
| Idempotency | Repeated execution yields the same result | Deterministic keys, checksums, or "if‑not‑exists" checks |
| Circuit Breaker | Calls are short‑circuited after a threshold of failures | State machine (Closed → Open → Half‑Open) |
| Exponential Backoff + Jitter | Retry spacing grows, reducing contention | delay = base * 2^attempt + random(0, jitter) |
| Coordinated Retry | Multiple agents avoid retry storms via consensus or leader election | Quorum, lease, or distributed lock |
These primitives can be layered; each addresses a different failure mode.
Applying Idempotency to AI Agents
The problem
An agent that calls a tool to "create user profile" may be retried on a transient network error, producing duplicate profiles.
Solution
Make the tool call idempotent by using a deterministic identifier derived from the request payload (e.g., a hash of the input). The service layer then checks existence before mutation.
Pseudocode (Python‑like)
import hashlib
def create_user_profile(user_id: str, data: dict) -> str:
# deterministic idempotency key
key = hashlib.sha256(f"{user_id}:{json.dumps(data, sort_keys=True)}".encode()).hexdigest()
# Check if already exists (could be a DB unique constraint)
existing = db.get_profile_by_key(key)
if existing:
return existing.profile_id
# Otherwise create
profile_id = db.insert_profile(key, user_id, data)
return profile_id
ponytail: global lock on the check‑then‑insert sequence; replace with DB unique constraint or atomic upsert if throughput matters.
Circuit Breaker for Model Endpoints
The problem
Repeatedly calling a failing LLM endpoint wastes quota and adds latency.
Solution
Wrap the call in a circuit breaker. After failure_threshold consecutive failures, open the circuit and return a fallback (e.g., cached response or error) for a timeout period.
Minimal implementation (using pybreaker)
import pybreaker
breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=30)
@breaker
def call_llm(prompt: str) -> str:
response = llm_client.generate(prompt)
return response.text
When the breaker is open, the agent can either:
- Return a default answer
- Degrade to a smaller, cheaper model
- Queue the request for later processing
Exponential Backoff with Jitter
The problem
Fixed‑interval retries cause thundering herd when the dependency recovers.
Solution
Increase delay exponentially and add random jitter to spread retries.
Helper
import random, time
def retry_with_backoff(func, *args, base=0.5, cap=30.0, jitter=0.1, max_attempts=5):
attempt = 0
while attempt < max_attempts:
try:
return func(*args)
except Exception as e:
if attempt == max_attempts - 1:
raise
delay = min(base * (2 ** attempt) + random.uniform(0, jitter), cap)
time.sleep(delay)
attempt += 1
Use this wrapper around any external call (model, tool, DB).
Coordinated Retry – Avoiding Storms Across Agents
When many agents share the same backend (e.g., a vector‑store), uncoordinated retries can overwhelm it.
Pattern: Leader‑Lease for Retry Windows
- Elect a leader (using etcd, Consul, or a simple distributed lock).
- The leader holds a lease for a retry window (e.g., 10 s).
- Only the leader may issue retries during its window; others wait or exponential‑backoff locally.
- On lease loss, a new leader is elected and the window resets.
This guarantees that at most N agents retry simultaneously, where N is the lease size (often 1).
Sketch (using kazoo for Zookeeper)
from kazoo.client import KazooClient
from kazoo.recipe.lock import Lock
zk = KazooClient(hosts='127.0.0.1:2181')
zk.start()
lock_path = '/retry_lock'
lock = Lock(zk, lock_path)
def safe_retry(callable):
with lock: # blocks until lock acquired
return callable()
ponytail: global lock; replace with sharded locks or per‑key retries if throughput becomes a bottleneck.
Observability & Tracing
Retry loops are invisible without proper instrumentation.
-
Metrics:
retry_attempts_total,circuit_breaker_state,backoff_delay_seconds. -
Logs: Emit a structured log on each attempt with
attempt_id,error_type,elapsed_ms. - Traces: Propagate a trace ID across retries so a single logical request appears as one span with child retry spans.
These signals let you tune thresholds and detect when a circuit breaker is opening too often.
Putting It All Together – Example Agent
Below is a simplified agent that summarizes a document using an LLM, stores the summary, and notifies a Slack channel.
import time, random, hashlib, json
import pybreaker
from kazoo.client import KazooClient
from kazoo.recipe.lock import Lock
# --- Setup ---
zk = KazooClient(hosts='zk:2181')
zk.start()
retry_lock = Lock(zk, '/agent_retry_lock')
breaker = pybreaker.CircuitBreaker(fail_max=3, reset_timeout=20)
# --- Helper ---
def idempotency_key(doc_id: str) -> str:
return hashlib.sha256(doc_id.encode()).hexdigest()
def backoff(attempt: int) -> float:
return min(0.5 * (2 ** attempt) + random.uniform(0, 0.1), 5.0)
# --- Core logic ---
def process_document(doc_id: str, content: str):
key = idempotency_key(doc_id)
# 1. Check if summary already exists (idempotent)
existing = db.get_summary_by_key(key)
if existing:
return existing.summary
# 2. Call LLM with circuit breaker + retry
@breaker
def llm_call(prompt):
return llm_client.generate(prompt).text
prompt = f"Summarize: {content[:2000]}"
attempt = 0
while True:
try:
with retry_lock: # coordinated retry window
summary = llm_call(prompt)
break
except pybreaker.CircuitBreakerError:
# fallback: use a smaller model or return placeholder
summary = "[Summary unavailable]"
break
except Exception as exc:
if attempt >= 4: # give up after 5 tries
raise
delay = backoff(attempt)
time.sleep(delay)
attempt += 1
# 3. Persist result (idempotent insert)
db.insert_summary_if_missing(key, doc_id, summary)
# 4. Notify Slack (fire‑and‑forget, but could also be retried)
slack_client.post_message(f"Summary for {doc_id}: {summary}")
return summary
The agent now:
- Guarantees at‑most‑one summary per document (idempotency).
- Protects the LLM endpoint with a circuit breaker.
- Spreads retries with exponential backoff + jitter.
- Prevents a thundering herd across instances via a distributed lock.
- Emits metrics/logs (omitted for brevity).
Frequently Asked Questions
Q: Isn’t adding a circuit breaker and a lock overkill for a simple agent?
A: The overhead is negligible (a few microseconds) compared to the cost of a failed LLM call or duplicate work. Enable them only when you observe retries or failures; they can be feature‑toggled.
Q: How do I choose the backoff base and cap?
A: Start with base = 0.5 s and cap = 5 s. Adjust based on observed latency SLOs and the retry‑cost of the downstream service.
Q: What if my tool doesn’t support idempotency keys?
A: Wrap the tool in a service layer that persists a deterministic request‑ID and checks it before forwarding the call. The storage can be a simple key‑value store with a TTL.
Conclusion
Applying distributed‑systems primitives transforms AI agents from fragile scripts into reliable services. Idempotency prevents duplicate side effects, circuit breakers halt useless load, exponential backoff with jitter avoids thundering herds, and coordinated retries keep shared backends healthy. Layer these patterns incrementally, instrument relentlessly, and let the system self‑heal rather than relying on endless happy‑path assumptions.
Built with the ponytail mindset: start minimal, add only what the failure mode demands.
4. The Circuit Breaker Pattern: Stopping the Bleeding
When an AI agent repeatedly calls a failing external LLM API or a downstream microservice, the local system can become saturated with retry traffic, leading to cascading failures. The Circuit Breaker pattern prevents this by detecting failures and temporarily stopping requests to the failing service, allowing it time to recover.
Implementation Strategy
A circuit breaker typically has three states:
- Closed: Requests flow normally. If failures exceed a threshold, the circuit opens.
- Open: Requests are blocked immediately (or fail fast). After a timeout, the circuit moves to the next state.
- Half-Open: A limited number of test requests are allowed through. If they succeed, the circuit closes; if they fail, it re-opens.
Runnable Code Example: Exponential Backoff with Circuit Breaker Logic
import time
import random
from enum import Enum
from typing import Optional, Callable
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.state = CircuitState.CLOSED
self.last_failure_time: Optional[float] = None
def call(self, func: Callable, *args, **kwargs):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("Circuit is OPEN. Service unavailable.")
try:
result = func(*args, **kwargs)
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.CLOSED
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise e
# Usage in an AI Agent Retry Loop
def call_llm_api(prompt: str) -> str:
# Simulate intermittent failure
if random.random() < 0.3:
raise ConnectionError("LLM API Timeout")
return f"Response to: {prompt}"
breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=10)
for i in range(10):
try:
response = breaker.call(call_llm_api, f"Query {i}")
print(f"Success: {response}")
except Exception as e:
print(f"Failed: {e}")
time.sleep(1)
5. Idempotency and Deduplication: Ensuring Safe Retries
In distributed systems, retries are inherently risky because they can cause duplicate side effects (e.g., charging a credit card twice, sending two emails, or updating a database record redundantly). Idempotency ensures that making the same request multiple times has the same effect as making it once.
Key Techniques for AI Agents
- Idempotency Keys: Generate a unique UUID for each agent action. The downstream service stores this key and rejects subsequent requests with the same key.
- State Verification: Before executing an action, check the current state. If the action was already completed, skip it.
- Transactional Outboxes: Use a database transaction to log the intent to act and then publish the event, ensuring exactly-once semantics.
Code Example: Idempotency Check in a Tool Call
import uuid
from datetime import datetime
class IdempotencyStore:
def __init__(self):
# In production, use Redis or a Database
self.executed_actions = {}
def execute_if_new(self, action_id: str, action_func: Callable):
if action_id in self.executed_actions:
print(f"Duplicate action detected: {action_id}. Skipping.")
return self.executed_actions[action_id]
result = action_func()
self.executed_actions[action_id] = {
"result": result,
"timestamp": datetime.now()
}
return result
# AI Agent Tool Execution
agent_action_id = str(uuid.uuid4())
store = IdempotencyStore()
def send_email(to: str, body: str):
print(f"Sending email to {to}: {body}")
return "Email Sent"
# Simulating a retry loop
for attempt in range(3):
try:
# Even if retried, the idempotency store prevents duplicate execution
result = store.execute_if_new(agent_action_id, lambda: send_email("user@example.com", "Hello"))
print(f"Attempt {attempt + 1}: {result}")
break
except Exception as e:
print(f"Attempt {attempt + 1} failed: {e}")
time.sleep(1)
6. Observability: Instrumenting for Failure
You cannot fix what you cannot measure. Distributed systems require comprehensive observability across three pillars: Logs, Metrics, and Traces.
Critical Metrics for AI Agents
- Retry Rate: Number of retries per successful request. A high rate indicates instability.
- Latency Percentiles (p95, p99): Average latency hides tail latency issues that cause timeouts.
- Error Rates by Component: Distinguish between LLM API errors, network timeouts, and agent logic errors.
- Circuit Breaker State: Monitor how often circuits open and close.
Distributed Tracing Integration
Use OpenTelemetry or similar frameworks to trace requests across agent components. This allows you to see exactly where a retry loop started and which dependency failed.
# Pseudo-code for OpenTelemetry Integration
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
def agentic_workflow(user_query: str):
with tracer.start_as_current_span("agent_workflow") as span:
span.set_attribute("user_query", user_query)
# Trace the retry loop
with tracer.start_as_current_span("tool_call_retry_loop") as span:
for attempt in range(MAX_RETRIES):
try:
result = call_external_service(user_query)
span.set_attribute(f"attempt_{attempt}", "success")
return result
except Exception as e:
span.set_attribute(f"attempt_{attempt}", f"failed: {str(e)}")
time.sleep(backoff(attempt))
span.set_attribute("final_status", "failed_after_max_retries")
raise Exception("Workflow failed")
7. Conclusion: Embracing Failure as a Feature
Building reliable AI agents requires shifting from a "happy path" mentality to one that assumes failure is inevitable. By applying distributed systems principles—retry policies with exponential backoff, circuit breakers, idempotency, and robust observability—you can create agents that are resilient, self-healing, and trustworthy.
The key takeaway is not to eliminate retries, but to control them. Implement patterns incrementally, starting with the most critical failure modes. Instrument everything, and let data drive your reliability improvements.
Built with the ponytail mindset: start minimal, add only what the failure mode demands.
Top comments (0)