| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- import type { NamedError } from "@opencode-ai/core/util/error"
- import { Cause, Clock, Duration, Effect, Schedule } from "effect"
- import { MessageV2 } from "./message-v2"
- import { iife } from "@/util/iife"
- export type Err = ReturnType<NamedError["toObject"]>
- export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
- export const GO_UPSELL_URL = "https://opencode.ai/go"
- export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
- export type Retryable = {
- message: string
- action?: {
- reason: RetryReason
- provider: string
- title: string
- message: string
- label: string
- link?: string
- }
- }
- 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, provider: string) {
- // context overflow errors should not be retried
- if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
- if (MessageV2.APIError.isInstance(error)) {
- const status = error.data.statusCode
- // 5xx errors are transient server failures and should always be retried,
- // even when the provider SDK doesn't explicitly mark them as retryable.
- if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
- if (error.data.responseBody?.includes("FreeUsageLimitError")) {
- return {
- message: GO_UPSELL_MESSAGE,
- action: {
- reason: "free_tier_limit",
- provider,
- title: "Free limit reached",
- message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
- label: "subscribe",
- link: GO_UPSELL_URL,
- },
- }
- }
- if (error.data.responseBody?.includes("GoUsageLimitError")) {
- const body = parseJSON(error.data.responseBody)
- const workspace = str(body?.metadata?.workspace)
- const limitName = str(body?.metadata?.limitName)
- const retryAfter = num(error.data.responseHeaders?.["retry-after"])
- const resetIn = iife(() => {
- if (retryAfter === undefined) return ""
- const seconds = Math.max(0, Math.ceil(retryAfter))
- const days = Math.floor(seconds / 86_400)
- const hours = Math.floor((seconds % 86_400) / 3_600)
- const minutes = Math.ceil((seconds % 3_600) / 60)
- const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}`
- if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day")
- if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour")
- return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
- })
- const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
- const link = `https://opencode.ai/workspace/${workspace}/go`
- return {
- message: `${message} - ${link}`,
- action: {
- reason: "account_rate_limit",
- provider,
- title: "Go limit reached",
- message,
- label: "open settings",
- link,
- },
- }
- }
- return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
- }
- // Check for rate limit patterns in plain text error messages
- const msg = error.data?.message
- if (typeof msg === "string") {
- const lower = msg.toLowerCase()
- if (
- lower.includes("rate increased too quickly") ||
- lower.includes("rate limit") ||
- lower.includes("too many requests")
- ) {
- return { message: msg }
- }
- }
- const json = parseJSON(error.data?.message)
- 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 { message: "Too Many Requests" }
- }
- if (code.includes("exhausted") || code.includes("unavailable")) {
- return { message: "Provider is overloaded" }
- }
- if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
- return { message: "Rate Limited" }
- }
- return undefined
- }
- function str(value: unknown) {
- if (value === undefined || value === null) return ""
- return String(value)
- }
- function num(value: unknown) {
- const parsed = Number.parseFloat(str(value))
- if (Number.isNaN(parsed)) return undefined
- return parsed
- }
- function parseJSON(value: unknown) {
- return iife(() => {
- try {
- if (typeof value !== "string") return undefined
- return JSON.parse(value)
- } catch {
- return undefined
- }
- })
- }
- export function policy(opts: {
- provider: string
- parse: (error: unknown) => Err
- set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
- }) {
- return Schedule.fromStepWithMetadata(
- Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
- const error = opts.parse(meta.input)
- const retry = retryable(error, opts.provider)
- if (!retry) 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: retry.message,
- action: retry.action,
- next: now + wait,
- })
- return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
- })
- }),
- )
- }
- export * as SessionRetry from "./retry"
|