refactor(session): effectify session processor (#19485)

This commit is contained in:
Kit Langton
2026-03-28 12:09:47 -04:00
committed by GitHub
parent 2b86b36c8c
commit 860531c275
11 changed files with 2159 additions and 593 deletions
+41 -36
View File
@@ -1,28 +1,18 @@
import type { NamedError } from "@opencode-ai/util/error"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
export namespace SessionRetry {
export type Err = ReturnType<NamedError["toObject"]>
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
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 })
})
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
export function delay(attempt: number, error?: MessageV2.APIError) {
@@ -33,7 +23,7 @@ export namespace SessionRetry {
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) {
return parsedMs
return cap(parsedMs)
}
}
@@ -42,23 +32,23 @@ export namespace SessionRetry {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) {
// convert seconds to milliseconds
return Math.ceil(parsedSeconds * 1000)
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 Math.ceil(parsed)
return cap(Math.ceil(parsed))
}
}
return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
}
}
return Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
}
export function retryable(error: ReturnType<NamedError["toObject"]>) {
export function retryable(error: Err) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
@@ -80,22 +70,37 @@ export namespace SessionRetry {
return undefined
}
})
try {
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
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
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" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return "Rate Limited"
}
return undefined
}
export function policy(opts: {
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const message = retryable(error)
if (!message) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}),
)
}
}