diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..8e3a37103473 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -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?: { @@ -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, diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..f4029176bd21 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -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. diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 528760543656..3de181a70f18 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -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 }) =>