Error Recovery

Focus Deep-Dive

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

Primary role

Catch errors from LLM streaming, categorize them, select fallback models, and retry until success or exhaustion of the fallback chain.

Main inputs

Error objects from provider stream wrappers, the configured fallback model chain, per-provider cooldown state, and auth profile status.

Key decisions

Is this error retryable? Which model to try next? Should we probe a provider that recently failed? Has the abort signal been raised?

Outputs / side effects

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

DecisionWhat controls itWhy it matters
Error categoryHTTP status code + error body structure from the providerCategory determines retry eligibility, cooldown duration, and fallback strategy
RetryabilityCategory mapping: rate_limit, timeout, transient are retryable; auth_failure is only retryable if cooldown has expiredNon-retryable errors immediately produce a FallbackSummaryError — no point trying again
Cooldown probe eligibilityshouldAllowCooldownProbeForReason() checks whether the cooldown window is about to expireAllows the system to recover faster by probing a provider whose cooldown is nearly over, rather than skipping it entirely
Abort signal checkabort.ts raises the signal when the user cancels or STEER_ABORT_SETTLE_TIMEOUT_MS (5000ms) elapsesPrevents the fallback loop from continuing after the user has moved on
Fallback chain orderingConfiguration in model registry — typically primary → same-provider alternate → cross-provider alternateSame-provider fallback avoids auth re-negotiation; cross-provider fallback maximizes availability
Stop-reason recoveryattempt.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:

CategoryTriggerRetryableCooldown
rate_limitHTTP 429 or provider-specific rate limit responseYesUses Retry-After header or default 60s
context_overflowHTTP 400 with "context length" or "max tokens" in bodyYes (after compaction)None — triggers backpressure compaction first
timeoutNo response within LLM_IDLE_TIMEOUT_MSYesNone
auth_failureHTTP 401 or 403Only after cooldown expiryToken refresh period from auth-profiles.ts
provider_errorHTTP 500, 502, 503 from the providerYesExponential backoff starting at 1s
transientNetwork errors, connection resets, DNS failuresYesFixed 2s delay
Key detail: The 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);
}
Watch out: The fallback chain is iterated once — there is no retry of a model that already failed in the same cycle. If a transient error on the primary model would succeed on immediate retry, it still falls through to the next model. This is intentional: it prioritizes forward progress over optimistic retry on the same provider.

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.

Failure mode: If the abort signal fires during the fallback loop, the 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.

Design insight: By modelling stop-reason anomalies as errors, the system reuses the fallback chain for both hard failures (HTTP 500) and soft failures (content filter triggered). This unification simplifies the error-handling surface and ensures all failure modes have a consistent recovery path.

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:

StateDescriptionError Recovery Behavior
SpawnedSubagent created, resources allocatedNo error recovery needed — not yet streaming
RunningSubagent actively streaming from an LLMError recovery active; fallback chain available
Ended: okSubagent completed successfullyNo action — cleanup only
Ended: errorSubagent ended due to unrecoverable errorFallbackSummaryError propagated to parent agent
Ended: timeoutSubagent exceeded its time budgetAbort signal raised; partial results preserved
Ended: killedParent forcibly terminated the subagentNo 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;
  }
}
Key detail: The 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

ScenarioBehaviorCode Location
All models in cooldownThe 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 attemptThe 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-streamCategorized 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 compactionIf 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 bodySome 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 modelSingle-model chains fall through to FallbackSummaryError immediately on first failure. The soonestCooldownExpiry gives the caller a retry hint.model-fallback.ts

Configuration

Knob / ParameterDefaultDescription
STEER_ABORT_SETTLE_TIMEOUT_MS5000Time to wait for in-flight chunks to drain after an abort signal before terminating the connection
PROBE_THRESHOLD_MS5000If a provider's cooldown has less than this many ms remaining, a probe retry is allowed
DEFAULT_RATE_LIMIT_COOLDOWN_MS60000Fallback cooldown when the provider doesn't include a Retry-After header
PROVIDER_ERROR_BACKOFF_BASE_MS1000Initial backoff delay for provider errors (HTTP 5xx); doubles on each consecutive failure
TRANSIENT_ERROR_DELAY_MS2000Fixed delay before retrying after a transient (network) error
MAX_STOP_REASON_RECOVERY_ATTEMPTS2Maximum number of continuation prompts for unexpected stop reasons before falling back
FAILOVER_OBSERVATION_ENABLEDtrueWhether to log failover observations to the telemetry system

See also:

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