The SDK client fetch wrapper now applies a 30s timeout to non-streaming reads. Without it, a socket that neither resolves nor rejects keeps the directory bootstrap concurrency slot busy forever and the UI stays on "loading sessions". Long-lived streams (POST prompts, the /event SSE) are explicitly excluded so they are not cut off mid-flight. The normalized "request timed out" error is added to the retry allowlist alongside undici's "terminated" (the exact failure observed in #2470 when undici tears down a half-open upstream connection); both are transient while the managed OpenCode process restarts. Caller- initiated aborts keep their original error shape so a user-cancelled request is not retried. Tests cover: GET timeout fires after the bound, POST is not timed out, /event SSE is not timed out, caller abort wins, AbortError is not retried, and the SDK normalized error is retried 3x.
62 lines
1.9 KiB
TypeScript
62 lines
1.9 KiB
TypeScript
export interface RetryOptions {
|
|
attempts?: number
|
|
delay?: number
|
|
factor?: number
|
|
maxDelay?: number
|
|
retryIf?: (error: unknown) => boolean
|
|
}
|
|
|
|
// undici tears down half-open upstream connection with `TypeError: terminated`
|
|
// (exact failure from the #2470 logs); the SDK client also rejects reads with
|
|
// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS.
|
|
// Both are transient — managed process may be restarting.
|
|
const TRANSIENT_MESSAGES = [
|
|
"load failed",
|
|
"network connection was lost",
|
|
"network request failed",
|
|
"failed to fetch",
|
|
"econnreset",
|
|
"econnrefused",
|
|
"etimedout",
|
|
"socket hang up",
|
|
"opencode api unavailable",
|
|
"503",
|
|
"502",
|
|
"terminated",
|
|
"request timed out",
|
|
]
|
|
|
|
function isTransientError(error: unknown): boolean {
|
|
if (!error) return false
|
|
const message = String(error instanceof Error ? error.message : error).toLowerCase()
|
|
if (TRANSIENT_MESSAGES.some((m) => message.includes(m))) return true
|
|
// Any HTTP 5xx is considered transient — server-side issues during warmup
|
|
// (OpenCode reading sessions from disk, bridge not ready, etc.) are retryable.
|
|
const status = (error as { status?: number })?.status
|
|
if (typeof status === "number" && status >= 500 && status < 600) return true
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|