How OpenCode Implements Retry Logic for Failed LLM Operations: A Deep Dive into the Dual-Layer Architecture
OpenCode handles transient LLM failures through a dual-layer retry system that combines a generic exponential back-off utility with an LLM-specific session retry handler that honors HTTP Retry-After headers and supports user-initiated aborts.
OpenCode, the open-source AI coding assistant from anomalyco/opencode, implements sophisticated retry logic for failed LLM operations to ensure reliability when interacting with flaky provider APIs. The architecture separates generic async retry mechanics from domain-specific LLM error handling, allowing both reusable utilities and context-aware recovery strategies. This article examines the implementation across the utility layer and the session processing loop.
The Dual-Layer Retry Architecture
OpenCode's retry mechanism operates through two distinct layers that work in concert to handle failures gracefully.
The generic retry helper located in packages/util/src/retry.ts provides a reusable wrapper for any asynchronous function, implementing exponential back-off and transient error detection. This utility knows nothing about LLMs and can retry file system operations, network requests, or database queries.
The LLM-specific session retry module in packages/opencode/src/session/retry.ts contains domain logic for determining which LLM errors are recoverable, calculating delays based on provider headers, and performing abort-aware sleeps. This layer integrates directly into the session processor loop that manages the conversation state.
Generic Retry Utility for Async Operations
The foundation of OpenCode's resilience starts with a generic retry function that wraps any flaky async operation.
Exponential Back-Off Implementation
The retry() function in packages/util/src/retry.ts implements a configurable exponential back-off strategy:
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options
let lastError: unknown
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === attempts - 1 || !retryIf(error)) throw error
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
await new Promise((resolve) => setTimeout(resolve, wait))
}
}
throw lastError
}
This implementation defaults to three attempts with a base delay of 500 milliseconds, doubling the wait time after each failure while capping the maximum delay at 10 seconds. Callers can customize every parameter, including providing a custom retryIf predicate to determine which errors warrant a retry.
Transient Error Detection
The default isTransientError predicate identifies common network failure patterns such as "load failed", "econnreset", and "timeout" by inspecting error messages. This ensures that permanent failures like authentication errors or invalid arguments fail fast while transient network hiccups trigger the back-off sequence.
LLM-Specific Session Retry Logic
While the generic utility handles mechanical retry logic, the session layer adds semantic understanding of LLM provider errors.
Determining Retryability with retryable()
The SessionRetry.retryable() function in packages/opencode/src/session/retry.ts filters errors to determine which warrant a retry attempt:
export function retryable(error: ReturnType<NamedError["toObject"]>) {
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError"))
return `Free usage exceeded, add credits https://opencode.ai/zen`
return error.data.message.includes("Overloaded")
? "Provider is overloaded"
: error.data.message
}
// Attempt to parse a structured JSON error payload …
}
This function returns undefined for non-retryable errors like context overflow, while returning descriptive messages for retryable APIError instances that include flags like isRetryable: true or specific provider messages such as "Overloaded".
Honoring HTTP Retry-After Headers
When an LLM provider returns a 429 status or includes rate-limit headers, OpenCode's SessionRetry.delay() function prioritizes the provider's guidance over calculated back-off. It parses retry-after-ms (milliseconds), retry-after (seconds or HTTP-date format), and uses those exact values before falling back to exponential back-off:
export function delay(attempt: number, error?: MessageV2.APIError) {
if (error) {
const headers = error.data.responseHeaders
if (headers) {
const retryAfterMs = headers["retry-after-ms"]
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) return parsedMs
}
const retryAfter = headers["retry-after"]
if (retryAfter) {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) return Math.ceil(parsedSeconds * 1000)
// HTTP‑date format fallback
const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) return Math.ceil(parsed)
}
// No valid header → exponential back‑off
return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
}
}
// No error or no headers → capped exponential back‑off
return Math.min(
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
RETRY_MAX_DELAY_NO_HEADERS
)
}
The implementation starts with a 2000 millisecond base delay, doubling with each attempt (RETRY_BACKOFF_FACTOR = 2), while capping the delay at 30 seconds (RETRY_MAX_DELAY_NO_HEADERS) when no headers are present.
Abort-Aware Sleep Mechanism
To ensure responsiveness, OpenCode implements abort-aware retry logic through the SessionRetry.sleep() function. This utility accepts an AbortSignal and immediately rejects with a DOMException named "AbortError" if the user cancels the session during a retry delay:
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abortHandler = () => {
clearTimeout(timeout)
reject(new DOMException("Aborted", "AbortError"))
}
const timeout = setTimeout(() => {
signal.removeEventListener("abort", abortHandler)
resolve()
}, Math.min(ms, RETRY_MAX_DELAY))
signal.addEventListener("abort", abortHandler, { once: true })
})
}
This prevents the system from waiting through long back-off periods when the user has already terminated the operation, improving perceived responsiveness.
Integration in the Session Processor
The retry logic converges in the session processor located at packages/opencode/src/session/processor.ts. When an LLM stream throws an error, the processor executes a structured recovery sequence:
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
type: "retry",
attempt,
message: retry,
next: Date.now() + delay,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
continue // retry the whole processing loop
}
This integration demonstrates the complete retry lifecycle: error classification, delay calculation, UI state updates via SessionStatus, abort-aware waiting, and loop continuation for the next attempt. The processor maintains an attempt counter that persists across the while (true) loop, ensuring exponential back-off increases with each consecutive failure.
Practical Usage Examples
Using the Generic Retry Helper for Network Operations
Developers can leverage the generic retry utility for any flaky async operation beyond LLM calls:
import { retry } from "@opencode/util"
async function fetchWithRetry(url: string) {
return retry(() => fetch(url).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
}), { attempts: 5, delay: 1000 })
}
This example configures five attempts with a one-second base delay, demonstrating how the generic layer supports diverse use cases while maintaining the same exponential back-off semantics.
Simulating Session Retry Logic in Tests
When testing LLM integrations, developers can simulate the session retry behavior:
import { SessionRetry } from "@opencode/opencode/session"
const error = apiError({ "retry-after": "30" }) // mock APIError with header
const attempt = 2
// Determine if retryable
const reason = SessionRetry.retryable(error)
// Compute delay (honors header)
const ms = SessionRetry.delay(attempt, error)
// Execute abort-aware sleep
await SessionRetry.sleep(ms, AbortSignal.timeout(35_000))
This test pattern mirrors the production flow exactly, allowing verification that custom Retry-After values and exponential back-off calculations behave correctly under controlled conditions.
Summary
OpenCode's retry logic for failed LLM operations operates through a clean separation of concerns:
- Generic retry utility (
packages/util/src/retry.ts) provides reusable exponential back-off for any async function, with configurable attempts, delays, and transient error detection. - Session-specific retry logic (
packages/opencode/src/session/retry.ts) adds LLM domain intelligence, filtering non-retryable errors like context overflows while parsing HTTPRetry-Afterheaders for provider-specific delays. - Abort-aware execution ensures that user-initiated cancellations immediately terminate retry loops, preventing wasted resources during back-off periods.
- Processor integration (
packages/opencode/src/session/processor.ts) orchestrates the complete lifecycle, updating UI state viaSessionStatuswhile maintaining attempt counters across retry cycles.
Frequently Asked Questions
What types of LLM errors trigger a retry in OpenCode?
OpenCode only retries transient, recoverable errors such as rate limits, provider overloads, and network timeouts. Specifically, the SessionRetry.retryable() function returns undefined for non-retryable errors like ContextOverflowError, while returning descriptive messages for retryable APIError instances that include flags like isRetryable: true or specific provider messages such as "Overloaded".
How does OpenCode handle HTTP 429 rate limit responses?
When an LLM provider returns a 429 status or includes rate-limit headers, OpenCode's SessionRetry.delay() function prioritizes the provider's guidance over calculated back-off. It parses retry-after-ms (milliseconds), retry-after (seconds or HTTP-date format), and uses those exact values before falling back to exponential back-off starting at 2000ms with a factor of 2, capped at 30 seconds.
Can users cancel a retry operation mid-stream?
Yes, OpenCode implements abort-aware retry logic through the SessionRetry.sleep() function. This utility accepts an AbortSignal and immediately rejects with a DOMException named "AbortError" if the user cancels the session during a retry delay. This prevents the system from waiting through long back-off periods when the user has already terminated the operation.
Where is the retry configuration defined in the codebase?
Retry configuration is distributed across two main locations according to the separation of concerns. The generic retry defaults (3 attempts, 500ms base delay, factor of 2, 10s max) reside in packages/util/src/retry.ts. The LLM-specific constants (2000ms initial delay, 30s cap without headers, absolute maximum delay) are defined in packages/opencode/src/session/retry.ts, alongside the SessionRetry namespace functions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →