Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162export * as SessionRunnerRetry from "./retry.js"
import { AIError, isContextOverflowFailure } from "@opencode/ai"import { Agent } from "@opencode/schema/agent"import { Model } from "@opencode/schema/model"import { SessionError } from "@opencode/schema/session-error"import { Clock, Duration, Effect, Pull, Schedule } from "effect"import { Bus } from "../../bus.js"import type { PluginHooks } from "../../plugin/hooks.js"import { SessionEvent } from "../event.js"import { SessionMessage } from "../message.js"import { SessionSchema } from "../schema.js"import { toSessionError } from "../to-session-error.js"
interface Input { readonly cause: AIError readonly error: SessionError.Error readonly agent: Agent.ID readonly model: Model.Ref readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void> readonly retry: boolean}
export interface Decision { readonly retry: true readonly attempt: number readonly delay: number}
export function isRetryable(error: AIError) { const override = error.reason.http?.headers["x-should-retry"] if (override === "true") return true if (override === "false") return false switch (error.reason._tag) { case "RateLimit": case "ProviderInternal": return true // A WebSocket acknowledgment marks delivery accepted before model output may exist. // Read failures can still recover; the Step chooses retry versus continuation from durable output. case "Transport": return ( error.reason.delivery !== "rejected" && (error.reason.delivery !== "accepted" || error.reason.operation === "read") ) case "InvalidProviderOutput": return error.reason.classification === "incomplete-stream" // Unrecognized failures retry: classification records affirmative // deterministic evidence, and transient failures are exactly the ones // that arrive in shapes no classifier anticipates. case "UnknownProvider": return true case "Authentication": case "QuotaExceeded": case "ContentPolicy": case "InvalidRequest": case "UnsupportedOperation": case "NoRoute": case "Timeout": return false default: { const exhaustive: never = error.reason return exhaustive } }}
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
const retryAfter = (input: Input) => { if (input.cause.reason._tag === "RateLimit" || input.cause.reason._tag === "ProviderInternal") return input.cause.reason.retryAfterMs === undefined ? undefined : Math.min(input.cause.reason.retryAfterMs, RETRY_AFTER_MAX) return undefined}
// Exponential from 2s capped at 10s per gap, for 10 retries: 2, 4, 8, then 10 × 7, about 84s of// waiting when every attempt fails (67–101s with jitter). `min` takes the faster schedule, so the// cap applies per gap; `max` with `recurs` bounds the count.const schedule = Schedule.max([ Schedule.min([Schedule.exponential("2 seconds"), Schedule.spaced("10 seconds")]), Schedule.recurs(10),]).pipe( Schedule.jittered, Schedule.setInputType<Input>(), Schedule.modifyDelay(({ input, duration: delay }) => { const minimum = retryAfter(input) const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)) return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration)))) }),)
export const policy = (sessionID: SessionSchema.ID) => Effect.gen(function* () { const step = yield* Schedule.toStep(schedule) let attempt = 1 return (input: Input) => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined))) if (!next) return { retry: false as const } const [, duration] = next attempt++ const delay = Math.ceil(Duration.toMillis(duration)) const event: PluginHooks.Domains["session"]["retry"] = { sessionID, agent: input.agent, model: input.model, error: input.error, attempt, decision: input.retry ? { retry: true, delay } : { retry: false }, } yield* input.hook(event) if (!event.decision.retry) return event.decision const normalized = Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay return { retry: true as const, attempt, delay: normalized } }) })
/** * Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the * session retry hook adjust each decision. Context overflow is never transient: callers recover it. */export const transient = (decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) => <A, R>(effect: Effect.Effect<A, AIError, R>) => Effect.retry(effect, { while: (cause) => Effect.gen(function* () { if (isContextOverflowFailure(cause)) return false const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) }) if (!decision.retry) return false yield* Effect.sleep(decision.delay) return true }), })
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) => Effect.gen(function* () { const decide = yield* policy(sessionID) const wait = (input: { readonly decision: Decision readonly assistantMessageID: SessionMessage.ID readonly error: SessionError.Error }) => Effect.gen(function* () { const scheduled = yield* Clock.currentTimeMillis yield* bus.publish(SessionEvent.RetryScheduled, { sessionID, assistantMessageID: input.assistantMessageID, attempt: input.decision.attempt, at: scheduled + input.decision.delay, error: input.error, }) const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis)) yield* Effect.sleep(Duration.millis(remaining)) }) return { decide, wait } })