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
9 changes: 9 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ 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 & {})

// Providers often report these under a "retryable" HTTP status (typically
// 429, shared with transient rate limiting), but retrying can never succeed
// without user intervention (e.g. adding billing). Without this, a
// persistently exhausted quota retries forever instead of failing promptly.
const TERMINAL_PROVIDER_ERROR_CODES = new Set(["insufficient_quota", "usage_not_included"])

export type Retryable = {
message: string
action?: {
Expand Down Expand Up @@ -73,6 +79,9 @@ export function retryable(error: Err, provider: string) {
// 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
const body = parseJSON(error.data.responseBody)
const code = isRecord(body) && isRecord(body.error) && typeof body.error.code === "string" ? body.error.code : ""
if (TERMINAL_PROVIDER_ERROR_CODES.has(code)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
return {
message: GO_UPSELL_MESSAGE,
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ describe("opencode run (non-interactive subprocess)", () => {
30_000,
)

// Regression for #32: a persistently exhausted quota (reported by providers
// via a 429 status, the same status used for transient rate limiting) used
// to be retried forever with exponential backoff instead of failing. That
// starved dispatchers (e.g. herdr-bridge) of a reliable exit code: the
// process just never returned. Assert prompt nonzero exit.
cliIt.concurrent(
"exits nonzero promptly when the provider quota is exhausted (regression for #32)",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.error(429, {
error: {
message: "You exceeded your current quota",
type: "insufficient_quota",
code: "insufficient_quota",
},
})
const result = yield* opencode.run("say hi", { timeoutMs: 15_000 })
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(15_000)
}),
30_000,
)

// The test provider's SSE error item is interpreted by the SDK as an unknown
// finish, not a fatal provider/session error. Lock that distinction in so it
// is not accidentally used as the failure compatibility oracle.
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,57 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
),
)

it.live("session.processor effect tests do not retry insufficient_quota errors", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()

// Providers commonly report quota exhaustion via a 429 status (the
// same status used for transient rate limiting), but retrying can
// never succeed without user intervention (e.g. billing). A second
// queued reply proves a regression retried at all: if this ever
// becomes a second LLM call, the assertion on llm.calls below fails.
yield* llm.error(429, {
error: { message: "You exceeded your current quota", type: "insufficient_quota", code: "insufficient_quota" },
})
yield* llm.text("should not be reached")

const chat = yield* session.create({})
const parent = yield* user(chat.id, "quota")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})

const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "quota" }],
tools: {},
})

expect(value).toBe("stop")
expect(yield* llm.calls).toBe(1)
expect(handle.message.error?.name).toBe("APIError")
}),
{ config: (url) => providerCfg(url) },
),
)

it.live("session.processor effect tests retry recognized structured json errors", () =>
provideTmpdirServer(
({ dir, llm }) =>
Expand Down