2026-03-31 18:47:00 +03:00
|
|
|
export interface RetryOptions {
|
|
|
|
|
attempts?: number
|
|
|
|
|
delay?: number
|
|
|
|
|
factor?: number
|
|
|
|
|
maxDelay?: number
|
|
|
|
|
retryIf?: (error: unknown) => boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 11:37:20 +02:00
|
|
|
// 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.
|
2026-03-31 18:47:00 +03:00
|
|
|
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",
|
2026-08-17 11:37:20 +02:00
|
|
|
"terminated",
|
|
|
|
|
"request timed out",
|
2026-03-31 18:47:00 +03:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-20 21:42:16 +03:00
|
|
|
// Any HTTP 5xx is considered transient — server-side issues during warmup
|
|
|
|
|
// (OpenCode reading sessions from disk, bridge not ready, etc.) are retryable.
|
2026-03-31 18:47:00 +03:00
|
|
|
const status = (error as { status?: number })?.status
|
2026-04-20 21:42:16 +03:00
|
|
|
if (typeof status === "number" && status >= 500 && status < 600) return true
|
2026-03-31 18:47:00 +03:00
|
|
|
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
|
|
|
|
|
}
|