Catch errors from LLM streaming, categorize them, select fallback models, and retry until success or exhaustion of the fallback chain.
Error Recovery
Categorizing, handling, and recovering from LLM streaming errors via model fallback
When an LLM provider returns an error mid-stream — rate limit, context overflow, auth failure, or transient network issue — this module categorizes the error, selects the next model in the fallback chain, and retries transparently. If all models are exhausted, it produces a structured FallbackSummaryError with per-attempt diagnostics.
📚 What is this & why does it matter?
LLM providers are unreliable at scale: APIs rate-limit, tokens expire, context windows are exceeded, and networks drop packets. Without error recovery, a single transient failure would kill an entire agent session. This module ensures the streaming pipeline degrades gracefully — falling back to alternate models, probing recently-failed providers when their cooldowns expire, and surfacing structured diagnostics when all options are exhausted. Understanding this flow is critical for debugging why an agent switched models mid-conversation or why a session ended with a fallback error.
💡 Real-world analogy
Imagine a dispatcher at a taxi company. When a customer calls, the dispatcher tries the nearest available cab. If that driver's radio is down (transient error), they try the next driver. If a driver is on a mandatory break (rate limit cooldown), the dispatcher notes the break end time and skips them for now — but may try again if no other cabs are available and the break should be ending soon. If every single cab in the fleet is unavailable, the dispatcher calls the customer back with a detailed explanation: "Driver A was on break until 3pm, Driver B had a flat tire, Driver C's license expired." That explanation is the FallbackSummaryError.
Contents
Overview
Error objects from provider stream wrappers, the configured fallback model chain, per-provider cooldown state, and auth profile status.
Is this error retryable? Which model to try next? Should we probe a provider that recently failed? Has the abort signal been raised?
Either a successful retry on an alternate model or a FallbackSummaryError carrying per-attempt details and the soonest cooldown expiry time.
flowchart TD
ERR["Streaming Error"]
CAT["Categorize Error"]
RATE["rate_limit"]
CTX["context_overflow"]
TIMEOUT["timeout"]
AUTH["auth_failure"]
PROV["provider_error"]
TRANS["transient"]
RETRY{"Retryable?"}
ABORT{"Abort signal?"}
SELECT["Select next fallback model"]
PROBE{"Cooldown probe?"}
ATTEMPT["Attempt stream on fallback"]
SUCCESS["Success — resume streaming"]
EXHAUST["All models exhausted"]
FSE["FallbackSummaryError"]
OBS["failover-observation.ts — log event"]
ERR --> CAT
CAT --> RATE
CAT --> CTX
CAT --> TIMEOUT
CAT --> AUTH
CAT --> PROV
CAT --> TRANS
RATE --> RETRY
CTX --> RETRY
TIMEOUT --> RETRY
AUTH --> RETRY
PROV --> RETRY
TRANS --> RETRY
RETRY -->|No| FSE
RETRY -->|Yes| ABORT
ABORT -->|Yes| FSE
ABORT -->|No| SELECT
SELECT --> PROBE
PROBE -->|Yes, try recently-failed| ATTEMPT
PROBE -->|No, skip to next| SELECT
ATTEMPT -->|Success| SUCCESS
ATTEMPT -->|Failure| SELECT
SELECT -->|No more models| EXHAUST
EXHAUST --> FSE
ATTEMPT --> OBS
SUCCESS --> OBS
The overview flowchart traces an error from initial detection through categorization, the fallback selection loop, and the two possible outcomes: successful recovery on an alternate model, or a FallbackSummaryError when all options are exhausted. Note how failover-observation.ts logs every attempt regardless of outcome, enabling post-session analysis of failover patterns.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Error category | HTTP status code + error body structure from the provider | Category determines retry eligibility, cooldown duration, and fallback strategy |
| Retryability | Category mapping: rate_limit, timeout, transient are retryable; auth_failure is only retryable if cooldown has expired | Non-retryable errors immediately produce a FallbackSummaryError — no point trying again |
| Cooldown probe eligibility | shouldAllowCooldownProbeForReason() checks whether the cooldown window is about to expire | Allows the system to recover faster by probing a provider whose cooldown is nearly over, rather than skipping it entirely |
| Abort signal check | abort.ts raises the signal when the user cancels or STEER_ABORT_SETTLE_TIMEOUT_MS (5000ms) elapses | Prevents the fallback loop from continuing after the user has moved on |
| Fallback chain ordering | Configuration in model registry — typically primary → same-provider alternate → cross-provider alternate | Same-provider fallback avoids auth re-negotiation; cross-provider fallback maximizes availability |
| Stop-reason recovery | attempt.stop-reason-recovery.ts inspects unexpected stop reasons (e.g., content_filter) | Some stop reasons indicate the response was cut short but can be continued with a follow-up prompt |
How It Works
Error recovery operates as a state machine wrapping the streaming attempt loop. Each attempt either succeeds (returning the response payload) or fails (adding an entry to the fallback summary and advancing to the next model).
Phase 1 — Error Categorization
When a streaming error occurs, the system first categorizes it. Each provider wrapper translates its native error format into one of six canonical categories:
| Category | Trigger | Retryable | Cooldown |
|---|---|---|---|
rate_limit | HTTP 429 or provider-specific rate limit response | Yes | Uses Retry-After header or default 60s |
context_overflow | HTTP 400 with "context length" or "max tokens" in body | Yes (after compaction) | None — triggers backpressure compaction first |
timeout | No response within LLM_IDLE_TIMEOUT_MS | Yes | None |
auth_failure | HTTP 401 or 403 | Only after cooldown expiry | Token refresh period from auth-profiles.ts |
provider_error | HTTP 500, 502, 503 from the provider | Yes | Exponential backoff starting at 1s |
transient | Network errors, connection resets, DNS failures | Yes | Fixed 2s delay |
context_overflow category is special: before attempting a fallback, the system first tries to compact the conversation (via the backpressure module). Only if compaction fails or the compacted history still overflows does it proceed to model fallback.
Phase 2 — Model Fallback Chain
The model-fallback.ts module maintains an ordered list of models to try. When the primary model fails, it walks the chain and attempts each one. The chain is configured per-deployment and typically follows the pattern: primary model → same-provider cheaper model → cross-provider equivalent → cross-provider cheaper model.
// model-fallback.ts — core fallback loop
interface FallbackAttempt {
model: string;
error: CategorizedError;
durationMs: number;
cooldownExpiry: Date | null;
}
async function attemptWithFallback(
request: StreamRequest,
fallbackChain: string[],
signal: AbortSignal,
): Promise<StreamResponse> {
const attempts: FallbackAttempt[] = [];
for (const model of fallbackChain) {
if (signal.aborted) break;
const provider = getProviderForModel(model);
if (provider.isInCooldown(model)) {
if (!shouldAllowCooldownProbeForReason(provider, model)) {
continue; // skip — cooldown not yet expired
}
}
const start = Date.now();
try {
const response = await provider.stream(request, model, signal);
logFailoverObservation(model, "success", attempts);
return response;
} catch (err) {
const categorized = categorizeError(err, provider);
attempts.push({
model,
error: categorized,
durationMs: Date.now() - start,
cooldownExpiry: categorized.cooldownExpiry,
});
provider.recordFailure(model, categorized);
logFailoverObservation(model, "failure", attempts);
}
}
throw new FallbackSummaryError(attempts);
}
Phase 3 — Cooldown Probing
When a provider is in cooldown but the cooldown window is nearly expired (remainingMs < PROBE_THRESHOLD_MS), the system may issue a "cooldown probe" — a retry attempt that would normally be skipped. This enables faster recovery when rate limits expire mid-fallback-chain.
// shouldAllowCooldownProbeForReason()
const PROBE_THRESHOLD_MS = 5000;
function shouldAllowCooldownProbeForReason(
provider: Provider,
model: string,
): boolean {
const cooldown = provider.getCooldownState(model);
if (!cooldown) return true; // not in cooldown
const remainingMs = cooldown.expiresAt.getTime() - Date.now();
if (remainingMs <= 0) return true; // cooldown expired
// Only probe if nearly expired and the reason was rate_limit or transient
if (remainingMs < PROBE_THRESHOLD_MS) {
return cooldown.reason === "rate_limit" || cooldown.reason === "transient";
}
return false;
}
Phase 4 — FallbackSummaryError
When all models in the fallback chain have been tried and failed, the system creates a FallbackSummaryError. This error carries structured data for every attempt — which model was tried, what error it returned, how long it took, and when its cooldown expires. The soonestCooldownExpiry property tells the caller when the earliest retry opportunity will be available.
FallbackSummaryError will contain only partial attempt data. The aborted flag is set to true so callers know the error may not reflect the full fallback chain.
Abort Handling
The abort.ts module manages stream abort propagation. When the user cancels or the system determines the stream should stop, the abort signal is raised. However, to prevent data loss, the signal propagation includes a settle timeout (STEER_ABORT_SETTLE_TIMEOUT_MS = 5000) that allows in-flight chunks to drain before the connection is terminated.
Behavior Diagram
stateDiagram-v2
[*] --> Streaming
Streaming --> ErrorDetected : provider error
ErrorDetected --> Categorizing : inspect error
Categorizing --> CheckingRetryable : assign category
CheckingRetryable --> FallbackSummary : not retryable
CheckingRetryable --> CheckingAbort : retryable
CheckingAbort --> FallbackSummary : abort signal raised
CheckingAbort --> SelectingFallback : no abort
SelectingFallback --> CooldownProbe : model in cooldown, nearly expired
SelectingFallback --> AttemptingStream : model available
SelectingFallback --> FallbackSummary : no models remaining
CooldownProbe --> AttemptingStream : probe allowed
CooldownProbe --> SelectingFallback : probe denied, skip model
AttemptingStream --> Streaming : success
AttemptingStream --> SelectingFallback : failure, try next
FallbackSummary --> [*]
state Streaming {
[*] --> ReceivingChunks
ReceivingChunks --> ReceivingChunks : chunk received
ReceivingChunks --> StopReasonCheck : stream ended
StopReasonCheck --> [*] : normal stop
StopReasonCheck --> ErrorDetected : unexpected stop reason
}
The state diagram shows two levels: the outer error-recovery state machine and the inner streaming state. Note that the StopReasonCheck state inside streaming can transition to ErrorDetected when attempt.stop-reason-recovery.ts identifies an unexpected stop reason (e.g., content_filter, premature length stop). This means error recovery handles not just explicit errors but also abnormal completions.
ACP/Subagent Lifecycle
Error recovery interacts with the broader subagent lifecycle. Each subagent (ACP or spawned child) has its own lifecycle that error recovery must respect:
| State | Description | Error Recovery Behavior |
|---|---|---|
| Spawned | Subagent created, resources allocated | No error recovery needed — not yet streaming |
| Running | Subagent actively streaming from an LLM | Error recovery active; fallback chain available |
| Ended: ok | Subagent completed successfully | No action — cleanup only |
| Ended: error | Subagent ended due to unrecoverable error | FallbackSummaryError propagated to parent agent |
| Ended: timeout | Subagent exceeded its time budget | Abort signal raised; partial results preserved |
| Ended: killed | Parent forcibly terminated the subagent | No error recovery — intentional termination |
Annotated Code Walkthrough
Walk through the critical path from error detection to fallback resolution.
Error categorization maps provider-specific errors to canonical categories. Each provider wrapper implements the categorizeError() method:
// error-categorizer.ts
type ErrorCategory =
| "rate_limit"
| "context_overflow"
| "timeout"
| "auth_failure"
| "provider_error"
| "transient";
interface CategorizedError {
category: ErrorCategory;
retryable: boolean;
cooldownExpiry: Date | null;
originalError: Error;
providerMessage: string;
}
function categorizeError(err: unknown, provider: Provider): CategorizedError {
if (err instanceof TimeoutError) {
return { category: "timeout", retryable: true, cooldownExpiry: null,
originalError: err, providerMessage: "LLM idle timeout" };
}
const status = (err as any)?.status ?? 0;
if (status === 429) {
const retryAfter = parseRetryAfterHeader(err);
return { category: "rate_limit", retryable: true,
cooldownExpiry: new Date(Date.now() + retryAfter),
originalError: err as Error,
providerMessage: `Rate limited, retry after ${retryAfter}ms` };
}
if (status === 401 || status === 403) {
return { category: "auth_failure", retryable: false,
cooldownExpiry: null, originalError: err as Error,
providerMessage: "Authentication failed" };
}
if (status >= 500) {
return { category: "provider_error", retryable: true,
cooldownExpiry: new Date(Date.now() + 1000),
originalError: err as Error,
providerMessage: `Provider returned ${status}` };
}
if (isContextOverflowError(err)) {
return { category: "context_overflow", retryable: true,
cooldownExpiry: null, originalError: err as Error,
providerMessage: "Context window exceeded" };
}
return { category: "transient", retryable: true,
cooldownExpiry: new Date(Date.now() + 2000),
originalError: err as Error,
providerMessage: String(err) };
}
The FallbackSummaryError class aggregates all attempt data and computes the soonest cooldown expiry. This is what the caller receives when all models are exhausted:
// model-fallback.ts — FallbackSummaryError
class FallbackSummaryError extends Error {
readonly attempts: FallbackAttempt[];
readonly soonestCooldownExpiry: Date | null;
readonly aborted: boolean;
constructor(attempts: FallbackAttempt[], aborted = false) {
const models = attempts.map(a => a.model).join(", ");
super(`All fallback models exhausted: ${models}`);
this.name = "FallbackSummaryError";
this.attempts = attempts;
this.aborted = aborted;
// Find the earliest cooldown expiry across all attempts
this.soonestCooldownExpiry = attempts
.map(a => a.cooldownExpiry)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())[0] ?? null;
}
get retryableAt(): Date | null {
return this.soonestCooldownExpiry;
}
}
soonestCooldownExpiry enables smart retry scheduling upstream. Rather than using a fixed backoff, the caller can wait exactly until the first provider becomes available again.
Failover observations are logged for every attempt, successful or not. This provides an audit trail for debugging model switching:
// failover-observation.ts
interface FailoverObservation {
timestamp: Date;
model: string;
outcome: "success" | "failure";
errorCategory?: ErrorCategory;
durationMs: number;
attemptIndex: number;
totalAttempts: number;
}
function logFailoverObservation(
model: string,
outcome: "success" | "failure",
attempts: FallbackAttempt[],
): void {
const observation: FailoverObservation = {
timestamp: new Date(),
model,
outcome,
errorCategory: outcome === "failure"
? attempts[attempts.length - 1]?.error.category
: undefined,
durationMs: outcome === "failure"
? attempts[attempts.length - 1]?.durationMs ?? 0
: 0,
attemptIndex: attempts.length - 1,
totalAttempts: attempts.length,
};
telemetry.record("failover", observation);
}
Edge Cases & Error Handling
| Scenario | Behavior | Code Location |
|---|---|---|
| All models in cooldown | The system waits for soonestCooldownExpiry then retries the first-to-recover model. If STEER_ABORT_SETTLE_TIMEOUT_MS elapses while waiting, it aborts. | model-fallback.ts:attemptWithFallback() |
| Abort during fallback attempt | The signal.aborted check runs before each attempt. Mid-stream aborts trigger the settle timeout (5000ms) to drain in-flight chunks. | abort.ts |
| Auth token expires mid-stream | Categorized as auth_failure; auth-profiles.ts refreshes the token, and the system retries the same provider if the fallback chain hasn't moved past it. | auth-profiles.ts, error-categorizer.ts |
| Context overflow after compaction | If compaction fails to reduce below the limit, the error is re-categorized as provider_error and proceeds to model fallback (larger-context model). | model-fallback.ts, backpressure module |
Unexpected stop reason (content_filter) | attempt.stop-reason-recovery.ts treats this as a soft error; it appends a continuation prompt and retries on the same model before falling back. | attempt.stop-reason-recovery.ts |
| Provider returns 200 with error body | Some providers return HTTP 200 with an error in the response body. The stream wrapper detects this by checking for error fields in the parsed JSON. | *-stream-wrappers.ts |
| Fallback chain has only one model | Single-model chains fall through to FallbackSummaryError immediately on first failure. The soonestCooldownExpiry gives the caller a retry hint. | model-fallback.ts |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
STEER_ABORT_SETTLE_TIMEOUT_MS | 5000 | Time to wait for in-flight chunks to drain after an abort signal before terminating the connection |
PROBE_THRESHOLD_MS | 5000 | If a provider's cooldown has less than this many ms remaining, a probe retry is allowed |
DEFAULT_RATE_LIMIT_COOLDOWN_MS | 60000 | Fallback cooldown when the provider doesn't include a Retry-After header |
PROVIDER_ERROR_BACKOFF_BASE_MS | 1000 | Initial backoff delay for provider errors (HTTP 5xx); doubles on each consecutive failure |
TRANSIENT_ERROR_DELAY_MS | 2000 | Fixed delay before retrying after a transient (network) error |
MAX_STOP_REASON_RECOVERY_ATTEMPTS | 2 | Maximum number of continuation prompts for unexpected stop reasons before falling back |
FAILOVER_OBSERVATION_ENABLED | true | Whether to log failover observations to the telemetry system |
Related Topics
See also:
- Streaming overview — the parent L2 page covering the full streaming pipeline
- Chunk Assembly — how raw SSE events are normalized into unified payloads
- Backpressure & Flow Control — how the pipeline manages context window limits and compaction
Directory Map
Source files for this topic
src/agents/pi-embedded-runner/
├── fallback/
│ ├── model-fallback.ts ← Core fallback loop and FallbackSummaryError
│ ├── error-categorizer.ts ← Error categorization into canonical categories
│ └── failover-observation.ts ← Telemetry logging for fallback events
├── attempt/
│ └── attempt.stop-reason-recovery.ts ← Handles unexpected stop reasons
├── abort.ts ← Stream abort signal management
├── auth/
│ └── auth-profiles.ts ← Auth token management and cooldown recovery
└── streaming/
└── llm-idle-timeout.ts ← Idle connection timeout detection