Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 2 additions & 24 deletions packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
// Auto-update disabled in custom build

export async function upgrade() {
const config = await Config.global()
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
if (Installation.VERSION === latest) return

if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
return
}
if (config.autoupdate === "notify") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}

if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}
export async function upgrade() {}
15 changes: 15 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,21 @@ export namespace Config {
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
session: z
.object({
retry: z
.object({
enabled: z.boolean().optional().describe("Enable native retry logic (default: true)"),
delegate_to_plugin: z
.boolean()
.optional()
.describe("Delegate retry logic to active plugins (e.g., oh-my-opencode). When true, native retry is disabled."),
})
.optional()
.describe("Session retry configuration"),
})
.optional()
.describe("Session configuration"),
})
.strict()
.meta({
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,13 @@ export namespace SessionProcessor {
error,
})
} else {
// Check if retry should be delegated to plugins
const config = await Config.get()
const delegateRetryToPlugin = config.session?.retry?.delegate_to_plugin === true
const nativeRetryEnabled = config.session?.retry?.enabled !== false

const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
if (retry !== undefined && nativeRetryEnabled && !delegateRetryToPlugin) {
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
Expand Down
27 changes: 25 additions & 2 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,44 @@ export namespace SessionRetry {
export function retryable(error: ReturnType<NamedError["toObject"]>) {
// 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
// Check isRetryable at both top level and nested level
const isRetryable = error.data.isRetryable ?? true
if (!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
}

// Handle errors with nested structure (data.error.message)
const json = iife(() => {
try {
// Try top-level data.message first
if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message)
return parsed
}

return JSON.parse(error.data.message)
// Try nested data.error.message
if (typeof (error.data as any)?.error?.message === "string") {
const parsed = JSON.parse((error.data as any).error.message)
return parsed
}

// Try parsing responseBody directly
if (typeof error.data?.responseBody === "string") {
const parsed = JSON.parse(error.data.responseBody)
return parsed
}

return undefined
} catch {
return undefined
}
})

try {
if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : ""
Expand All @@ -93,6 +112,10 @@ export namespace SessionRetry {
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
// Also check for rate_limit_error type
if (json.error?.type === "rate_limit_error") {
return json.error.message || "Rate Limited"
}
return JSON.stringify(json)
} catch {
return undefined
Expand Down
46 changes: 46 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,49 @@ describe("session.message-v2.fromError", () => {
expect(result.data.isRetryable).toBe(true)
})
})

describe("session.retry.retryable nested error structures", () => {
test("recognizes Anthropic rate limit error with responseBody", () => {
const error = new MessageV2.APIError({
message: "This request would exceed your account's rate limit. Please try again later.",
isRetryable: true,
responseBody: JSON.stringify({
type: "error",
error: {
type: "rate_limit_error",
message: "This request would exceed your account's rate limit. Please try again later."
}
})
}).toObject()

const result = SessionRetry.retryable(error)
expect(result).toBeDefined()
expect(result).toContain("rate limit")
})

test("recognizes rate limit error with type in responseBody", () => {
const error = new MessageV2.APIError({
message: "Rate limit reached",
isRetryable: true,
responseBody: JSON.stringify({
error: {
type: "too_many_requests"
}
})
}).toObject()

const result = SessionRetry.retryable(error)
expect(result).toBeDefined()
})

test("handles isRetryable undefined as retryable", () => {
const error = new MessageV2.APIError({
message: "Some error",
isRetryable: undefined as any,
}).toObject()

const result = SessionRetry.retryable(error)
// When isRetryable is undefined, it defaults to true
expect(result).toBeDefined()
})
})
Loading