import type { NamedError } from "@opencode-ai/util/error" import { Clock, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import type { SessionID } from "./schema" import { SessionStatus } from "./status" import { iife } from "@/util/iife" export namespace SessionRetry { export type Err = ReturnType export const RETRY_INITIAL_DELAY = 2000 export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } 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 cap(parsedMs) } } const retryAfter = headers["retry-after"] if (retryAfter) { const parsedSeconds = Number.parseFloat(retryAfter) if (!Number.isNaN(parsedSeconds)) { // convert seconds to milliseconds return cap(Math.ceil(parsedSeconds * 1000)) } // Try parsing as HTTP date format const parsed = Date.parse(retryAfter) - Date.now() if (!Number.isNaN(parsed) && parsed > 0) { return cap(Math.ceil(parsed)) } } return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)) } } return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) } export function retryable(error: Err) { // context overflow errors should not be retried 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 } const json = iife(() => { try { if (typeof error.data?.message === "string") { const parsed = JSON.parse(error.data.message) return parsed } return JSON.parse(error.data.message) } catch { return undefined } }) try { if (!json || typeof json !== "object") return undefined const code = typeof json.code === "string" ? json.code : "" if (json.type === "error" && json.error?.type === "too_many_requests") { return "Too Many Requests" } if (code.includes("exhausted") || code.includes("unavailable")) { return "Provider is overloaded" } if (json.type === "error" && json.error?.code?.includes("rate_limit")) { return "Rate Limited" } return JSON.stringify(json) } catch { return undefined } } export function policy(opts: { sessionID: SessionID; parse: (error: unknown) => Err }) { let attempt = 0 return Schedule.identity().pipe( Schedule.while(({ input }: Schedule.InputMetadata) => Effect.succeed(retryable(opts.parse(input)) !== undefined), ), Schedule.addDelay((input) => Effect.gen(function* () { const error = opts.parse(input) const message = retryable(error) attempt += 1 const wait = delay(attempt, MessageV2.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis if (message) { yield* Effect.promise(() => SessionStatus.set(opts.sessionID, { type: "retry", attempt, message, next: now + wait, }), ) } return wait }), ), ) } }