Claude Code Subagent Error Recovery in 2026: Retry Logic, Fallback Strategies, and Detecting Stuck Loops
This article was written with the assistance of AI, under human supervision and review.
Most production agent failures trace back to a single omission: the absence of recovery logic. A subagent hits a rate limit, retries the same call immediately, burns through budget, and crashes the parent workflow. The coordinator receives a cryptic timeout after 90 seconds and has no idea which step failed or why.
Effective error recovery classifies failures by type, applies exponential backoff to transient issues, detects stuck loops before they drain budget, and propagates only the errors that require coordinator intervention. The difference shows up in uptime metrics and cost reports within days.
Key Takeaways
- Transient failures (rate limits, network blips) require retry logic with exponential backoff; logical failures (invalid parameters) need immediate propagation.
- Step repetition loops, where an agent retries identical tool calls, account for 15.7% of production failures and require hash-based detection.
- Circuit breakers prevent runaway costs by halting agents that exceed token budgets or attempt thresholds before they exhaust resources.
- Multi-agent systems should implement local recovery at the subagent level and propagate only errors the coordinator can resolve.
- Fallback strategies like model switching or degraded responses keep workflows running when primary approaches fail.
Error Classification: Transient vs Logical vs Fatal
Error recovery begins with classification. Not all failures deserve the same response. A rate limit from the Anthropic API is transient and recoverable. A schema validation error in tool parameters is logical and permanent. A memory exhaustion crash is fatal and requires process restart.
Transient errors include network timeouts, temporary service unavailability, and quota limits. These failures resolve with time or retry attempts. Retry logic with exponential backoff handles them without human intervention.
Logical errors stem from incorrect inputs, malformed requests, or violated preconditions. The agent passed an invalid file path to a tool. The database query referenced a nonexistent column. Retrying the same call will produce the same failure. These errors require immediate propagation to the coordinator or user.
Fatal errors signal systemic problems: out-of-memory conditions, corrupted state, unrecoverable resource exhaustion. Recovery at the agent level is impossible. The process must terminate gracefully and report the failure upstream.
Teams that skip classification treat all errors identically. They retry logical failures until a timeout expires, wasting budget on calls guaranteed to fail. They give up on transient failures after a single attempt, reporting unavailability when a two-second wait would have succeeded.
The classification layer sits at the entry point of your error handler. Inspect the error type, status code, or message pattern. Route transient errors to retry logic, logical errors to validation logging, and fatal errors to graceful shutdown.
Building Retry Logic with Exponential Backoff
Exponential backoff prevents retry storms that amplify outages. When a rate limit hits, immediate retries pound the API harder and extend the unavailability window. Each retry doubles the wait time: 1 second, 2 seconds, 4 seconds, up to a maximum delay.
The implementation tracks attempt count, calculates delay with jitter, and enforces a maximum retry limit:
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
jitterFactor: number;
}
async function withRetry<T>(
operation: () => Promise<T>,
config: RetryConfig,
isRetryable: (error: unknown) => boolean
): Promise<T> {
let attempt = 0;
while (attempt < config.maxAttempts) {
try {
return await operation();
} catch (error) {
attempt++;
if (!isRetryable(error) || attempt >= config.maxAttempts) {
throw error;
}
const exponentialDelay = config.baseDelayMs * Math.pow(2, attempt - 1);
const jitter = exponentialDelay * config.jitterFactor * Math.random();
const delayMs = Math.min(exponentialDelay + jitter, config.maxDelayMs);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error("retry logic exhausted without success or terminal error");
}
function isTransientError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes("rate limit") ||
message.includes("timeout") ||
message.includes("503") ||
message.includes("temporarily unavailable")
);
}
return false;
}
const result = await withRetry(
() => callClaudeAPI(prompt),
{ maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 16000, jitterFactor: 0.3 },
isTransientError
);
Jitter randomizes delays across concurrent agents. Without jitter, all failed agents retry at identical intervals and create synchronized load spikes. Jitter spreads retries across a time window.
The isRetryable predicate prevents retry loops on logical errors. Check the error message, status code, or custom error class. Return false for validation failures, authentication errors, or missing resources.
Exponential backoff visualization showing delay curves with and without jitter
Teams that omit retry logic lose uptime to momentary glitches. A single network hiccup terminates the workflow. Teams that retry without backoff amplify failures and rack up costs during outages.
Detecting and Breaking Stuck Loops: The Step Repetition Problem
Step repetition loops occur when an agent retries identical tool calls without recognizing the pattern. The agent requests a file that does not exist, receives an error, and immediately requests the same file with the same path. After 20 iterations, the budget is exhausted and no progress has occurred.
Detection requires hashing each attempted step and tracking recent history. If the same hash appears three times in the last five steps, the agent is stuck:
interface StepAttempt {
toolName: string;
parameters: Record<string, unknown>;
timestamp: number;
}
class LoopDetector {
private history: string[] = [];
private maxHistorySize = 10;
private repetitionThreshold = 3;
private lookbackWindow = 5;
recordStep(step: StepAttempt): void {
const hash = this.hashStep(step);
this.history.push(hash);
if (this.history.length > this.maxHistorySize) {
this.history.shift();
}
}
isStuck(): boolean {
if (this.history.length < this.repetitionThreshold) {
return false;
}
const recentSteps = this.history.slice(-this.lookbackWindow);
const latestHash = recentSteps[recentSteps.length - 1];
const matchCount = recentSteps.filter(h => h === latestHash).length;
return matchCount >= this.repetitionThreshold;
}
private hashStep(step: StepAttempt): string {
const normalized = JSON.stringify({
tool: step.toolName,
params: step.parameters
});
return this.simpleHash(normalized);
}
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(36);
}
reset(): void {
this.history = [];
}
}
const detector = new LoopDetector();
for (const step of agentSteps) {
detector.recordStep(step);
if (detector.isStuck()) {
throw new Error(
`agent stuck in repetition loop: attempted ${step.toolName} ` +
`with identical parameters ${detector["repetitionThreshold"]} times`
);
}
await executeStep(step);
}
The hash function normalizes tool name and parameters to catch semantic equivalence. Two calls with differently ordered JSON keys but identical content produce the same hash.
When a loop is detected, the agent must either request human intervention, switch to a fallback approach, or terminate the workflow with a descriptive error. Continuing execution wastes resources and delays failure diagnosis.
Some loops arise from environment state. The agent checks if a file exists, finds it missing, attempts to create it, and the creation fails silently. The next iteration repeats the check and creation. Loop detection surfaces the underlying issue: file creation permissions are missing.
Teams that ignore loop detection discover stuck agents hours later when budget alerts fire. The agent consumed $200 in tokens retrying a single failed operation. The workflow never progressed past step two.
Fallback Strategies: Model Switching and Degraded Responses
When primary recovery fails, fallback strategies keep workflows running at reduced capacity. Model switching drops to a cheaper or faster model when the primary model is unavailable. Degraded responses return partial results instead of failing completely.
Model switching maintains a priority list. If Claude 3.5 Sonnet hits rate limits, the coordinator switches to Claude 3 Haiku for less critical subagents. The workflow continues with reduced accuracy but meets the deadline:
interface ModelConfig {
modelId: string;
priority: number;
costPerToken: number;
maxTokens: number;
}
class ModelFallbackStrategy {
private models: ModelConfig[] = [
{ modelId: "claude-3-5-sonnet-20241022", priority: 1, costPerToken: 0.003, maxTokens: 200000 },
{ modelId: "claude-3-5-haiku-20241022", priority: 2, costPerToken: 0.0008, maxTokens: 200000 },
{ modelId: "claude-3-haiku-20240307", priority: 3, costPerToken: 0.00025, maxTokens: 200000 }
];
private currentIndex = 0;
async callWithFallback<T>(
operation: (modelId: string) => Promise<T>
): Promise<T> {
while (this.currentIndex < this.models.length) {
const model = this.models[this.currentIndex];
try {
return await operation(model.modelId);
} catch (error) {
if (this.shouldFallback(error) && this.currentIndex < this.models.length - 1) {
console.warn(
`model ${model.modelId} failed, falling back to ${this.models[this.currentIndex + 1].modelId}`
);
this.currentIndex++;
} else {
throw error;
}
}
}
throw new Error("all fallback models exhausted");
}
private shouldFallback(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes("rate limit") ||
message.includes("overloaded") ||
message.includes("503")
);
}
return false;
}
reset(): void {
this.currentIndex = 0;
}
}
const strategy = new ModelFallbackStrategy();
const response = await strategy.callWithFallback(async (modelId) => {
return await anthropic.messages.create({
model: modelId,
max_tokens: 4096,
messages: [{ role: "user", content: prompt }]
});
});
Degraded responses return partial data when complete results are unavailable. A document summarization agent that cannot access three of ten source files returns a summary of the seven it processed, along with a warning about missing sources.
The key decision: when does partial success outweigh complete failure? If the workflow is time-critical and 70% accuracy suffices, degraded responses are appropriate. If correctness is paramount, fail fast and propagate the error.
Model fallback cascade showing priority tiers from Sonnet to Haiku
Teams that lack fallback strategies experience all-or-nothing availability. A single rate limit brings down the entire multi-agent system. Teams with fallback strategies maintain partial functionality during degraded conditions and meet deadlines that rigid systems miss.
Error Propagation in Multi-Agent Systems
Subagents should recover locally from transient failures before propagating errors to the coordinator. Only errors that require coordinator intervention or represent unrecoverable logical failures should propagate upward.
A file processing subagent that encounters a rate-limited API call retries with exponential backoff. If retries succeed, the coordinator never learns about the transient failure. If all retries fail, the subagent propagates a structured error with context:
Error objects must carry sufficient context for the coordinator to make informed decisions. Include the subagent ID, the failed operation, relevant parameters, and prior recovery attempts:
interface SubagentError {
subagentId: string;
operation: string;
parameters: Record<string, unknown>;
originalError: string;
retryAttempts: number;
recoveryStrategy: string;
timestamp: number;
}
class Subagent {
private id: string;
private retryConfig: RetryConfig;
async executeTask(task: Task): Promise<TaskResult> {
try {
return await withRetry(
() => this.performTask(task),
this.retryConfig,
isTransientError
);
} catch (error) {
const structured: SubagentError = {
subagentId: this.id,
operation: task.operation,
parameters: task.parameters,
originalError: error instanceof Error ? error.message : String(error),
retryAttempts: this.retryConfig.maxAttempts,
recoveryStrategy: "exponential backoff",
timestamp: Date.now()
};
throw structured;
}
}
private async performTask(task: Task): Promise<TaskResult> {
// actual task execution
return { status: "completed", data: {} };
}
}
class Coordinator {
async orchestrate(subagents: Subagent[], tasks: Task[]): Promise<void> {
const results = await Promise.allSettled(
tasks.map((task, i) => subagents[i].executeTask(task))
);
for (const result of results) {
if (result.status === "rejected") {
const error = result.reason as SubagentError;
if (this.canCompensate(error)) {
await this.applyCompensation(error);
} else {
throw new Error(
`unrecoverable subagent failure: ${error.subagentId} ` +
`failed ${error.operation} after ${error.retryAttempts} attempts`
);
}
}
}
}
private canCompensate(error: SubagentError): boolean {
return error.operation === "fetch_optional_data";
}
private async applyCompensation(error: SubagentError): Promise<void> {
console.log(`compensating for optional operation failure: ${error.operation}`);
}
}
The coordinator inspects each error and decides whether to retry with a different subagent, apply a compensation strategy, or fail the entire workflow. Optional operations that fail may warrant compensation rather than termination.
Correlation IDs link errors across agent boundaries. When a subagent propagates an error, it includes the correlation ID from the originating request. The coordinator can trace the failure back through the call chain and identify the root cause. For more on correlation IDs in agent systems, see correlation IDs for AI agents.
Teams that propagate every error overwhelm coordinators with noise. Transient failures that resolve locally should remain invisible. Teams that swallow all errors at the subagent level leave coordinators blind to systemic issues.
Circuit Breakers and Budget Caps for Runaway Agents
Circuit breakers prevent runaway agents from exhausting budgets or API quotas. When an agent exceeds a token threshold or failure count, the circuit breaker trips and halts execution before damage compounds.
Token budget tracking measures cumulative token usage. When usage exceeds 80% of the allocated budget, the circuit breaker issues a warning. At 100%, it terminates the agent:
interface CircuitBreakerConfig {
tokenBudget: number;
maxFailures: number;
resetTimeoutMs: number;
}
enum CircuitState {
CLOSED = "closed",
OPEN = "open",
HALF_OPEN = "half_open"
}
class CircuitBreaker {
private state = CircuitState.CLOSED;
private tokenUsage = 0;
private failures = 0;
private lastFailureTime = 0;
constructor(private config: CircuitBreakerConfig) {}
recordTokens(count: number): void {
this.tokenUsage += count;
const usagePercent = (this.tokenUsage / this.config.tokenBudget) * 100;
if (usagePercent >= 80 && usagePercent < 100) {
console.warn(`circuit breaker warning: ${usagePercent.toFixed(1)}% of token budget consumed`);
}
if (this.tokenUsage >= this.config.tokenBudget) {
this.trip("token budget exceeded");
}
}
recordFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.config.maxFailures) {
this.trip("max failures exceeded");
}
}
recordSuccess(): void {
if (this.state === CircuitState.HALF_OPEN) {
this.reset();
}
}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === CircuitState.OPEN) {
if (Date.now() - this.lastFailureTime >= this.config.resetTimeoutMs) {
this.state = CircuitState.HALF_OPEN;
} else {
throw new Error("circuit breaker is open, rejecting operation");
}
}
try {
const result = await operation();
this.recordSuccess();
return result;
} catch (error) {
this.recordFailure();
throw error;
}
}
private trip(reason: string): void {
this.state = CircuitState.OPEN;
console.error(`circuit breaker tripped: ${reason}`);
throw new Error(`circuit breaker open: ${reason}`);
}
private reset(): void {
this.state = CircuitState.CLOSED;
this.failures = 0;
console.info("circuit breaker reset to closed state");
}
getStatus() {
return {
state: this.state,
tokenUsage: this.tokenUsage,
tokenBudget: this.config.tokenBudget,
failures: this.failures,
maxFailures: this.config.maxFailures
};
}
}
const breaker = new CircuitBreaker({
tokenBudget: 50000,
maxFailures: 5,
resetTimeoutMs: 60000
});
for (const task of tasks) {
await breaker.execute(async () => {
const response = await callClaudeAPI(task.prompt);
breaker.recordTokens(response.usage.input_tokens + response.usage.output_tokens);
return response;
});
}
The half-open state tests recovery. After the reset timeout expires, the circuit breaker allows a single operation. If it succeeds, the circuit fully resets. If it fails, the circuit trips again and waits for another timeout.
Rate limit guards complement circuit breakers by enforcing request-per-minute caps. If an agent attempts more than 60 API calls per minute, the guard delays subsequent calls:
class RateLimitGuard {
private requests: number[] = [];
private windowMs = 60000;
private maxRequests = 60;
async acquire(): Promise<void> {
const now = Date.now();
this.requests = this.requests.filter(t => now - t < this.windowMs);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const delayMs = this.windowMs - (now - oldestRequest);
await new Promise(resolve => setTimeout(resolve, delayMs));
return this.acquire();
}
this.requests.push(now);
}
}
Teams that skip circuit breakers discover runaway agents when the monthly bill arrives. A single stuck agent consumed $2,000 in tokens because no guard halted execution. Teams with circuit breakers catch runaway conditions within seconds and cap losses at predefined thresholds.
For more on managing agent environments and secrets that influence error scenarios, see environment and secrets management for Claude Code subagents.
Frequently Asked Questions
What retry delay should I use for rate-limited API calls?
Start with a base delay of 1 second and double it for each attempt up to a maximum of 16 seconds. Add 30% jitter to prevent synchronized retries across concurrent agents. Most transient rate limits resolve within 5 to 10 seconds.
How do I prevent agents from retrying logical errors that will never succeed?
Classify errors by inspecting status codes, error messages, or custom error types. Return false from the isRetryable predicate for validation failures, authentication errors, and resource-not-found conditions. Only retry transient failures like timeouts, 503 responses, and temporary unavailability.
When should a subagent propagate an error to the coordinator instead of recovering locally?
Propagate errors that the subagent cannot resolve through retry logic or fallback strategies: authentication failures, missing required resources, exceeded token budgets, or logical errors in the task specification. Recover locally from transient network issues, rate limits, and temporary service unavailability.
How do I detect step repetition loops in agents that use different parameter values each attempt?
Hash the combination of tool name and normalized parameters. Two calls with identical tool and parameters produce the same hash even if parameter order differs. If the same hash appears three or more times in the last five steps, the agent is stuck in a repetition loop.
What token budget should I allocate per subagent to prevent runaway costs?
Allocate budgets based on task complexity and expected length. Simple classification tasks may need 5,000 tokens. Document generation tasks may require 50,000 tokens. Set the budget at 150% of typical usage and configure circuit breakers to trip at 100% to prevent overruns.
Conclusion
Error recovery separates production-ready agents from prototypes. Retry logic with exponential backoff recovers from transient failures without amplifying load. Loop detection catches stuck agents before they exhaust budgets. Circuit breakers halt runaway processes before costs spiral. Structured error propagation gives coordinators the context they need to compensate or fail gracefully.
The agents that survive production are the ones that expect failure, classify it accurately, and recover without human intervention. For more on packaging reusable recovery logic, see packaging and distribution guide for Claude Code plugins. Start with classification, add retry logic to transient paths, and install circuit breakers before the first deployment. The failures you handle gracefully never reach your incident log.








Top comments (2)
On newer V8 you can get the raw text back —
JSON.parsepasses a thirdargument with
context.source— and decide from there. We used it to turnunsafe integers into strings before anything else touched them.
Retry logic only gets you so far — the real win is detecting when a subagent is spinning in place and swapping strategies before burning tokens.