Skip to content
Closed
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
11 changes: 9 additions & 2 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderV2 } from "@opencode-ai/core/provider"
import { isContextOverflow } from "@opencode-ai/llm"
import { SessionRetry } from "@/session/retry"

export class HeaderTimeoutError extends Error {
public override readonly name = "ProviderHeaderTimeoutError"
Expand Down Expand Up @@ -181,13 +182,19 @@ export function parseAPICallError(input: { providerID: ProviderV2.ID; error: API
}

const metadata = input.error.url ? { url: input.error.url } : undefined
const responseBody = input.error.responseBody
// FreeUsageLimitError is daily quota exhaustion — show Go upsell, not a generic rate-limit string.
const freeLimit =
typeof responseBody === "string" && responseBody.includes("FreeUsageLimitError")
? SessionRetry.GO_UPSELL_MESSAGE
: undefined
return {
type: "api_error",
message: m,
message: freeLimit ?? m,
statusCode: input.error.statusCode,
isRetryable: input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable,
responseHeaders: input.error.responseHeaders,
responseBody: input.error.responseBody,
responseBody,
metadata,
}
}
Expand Down
18 changes: 17 additions & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"

Expand Down Expand Up @@ -180,6 +179,10 @@ function parseJSON(value: unknown) {
})
}

function isTerminalQuota(action: Retryable["action"]) {
return action?.reason === "free_tier_limit" || action?.reason === "account_rate_limit"
}

export function policy(opts: {
provider: string
parse: (error: unknown) => Err
Expand All @@ -191,6 +194,19 @@ export function policy(opts: {
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)
// Fixed-window Zen quotas cannot succeed before reset — surface upsell once, then stop.
if (isTerminalQuota(retry.action)) {
return Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
yield* opts.set({
attempt: meta.attempt,
message: retry.message,
action: retry.action,
next: now,
})
return yield* Cause.done(meta.attempt)
})
}
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/provider/error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { ProviderError } from "@/provider/error"
import { SessionRetry } from "@/session/retry"
import { ProviderV2 } from "@opencode-ai/core/provider"

describe("provider stream errors", () => {
test("retries provider stream errors without a code", () => {
Expand All @@ -22,3 +25,31 @@ describe("provider stream errors", () => {
})
})
})

describe("provider API call errors", () => {
test("rewrites FreeUsageLimitError message to Go upsell", () => {
const responseBody = JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Rate limit exceeded. Please try again later." },
})
const parsed = ProviderError.parseAPICallError({
providerID: ProviderV2.ID.make("opencode"),
error: new APICallError({
message: "Rate limit exceeded. Please try again later.",
url: "https://opencode.ai/zen/v1/chat/completions",
requestBodyValues: {},
statusCode: 429,
responseHeaders: { "retry-after": "30000" },
responseBody,
isRetryable: true,
}),
})

expect(parsed).toMatchObject({
type: "api_error",
message: SessionRetry.GO_UPSELL_MESSAGE,
statusCode: 429,
responseBody,
})
})
})
111 changes: 110 additions & 1 deletion packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Schedule, Schema, Clock } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
Expand Down Expand Up @@ -146,6 +146,115 @@ describe("session.retry.delay", () => {
expect(attempts).toStrictEqual([1, 2, 3, 4, 5])
}),
)

it.instance("policy surfaces free-tier limit once then stops without waiting", () =>
Effect.gen(function* () {
const updates: Array<{ attempt: number; message: string; action?: SessionRetry.Retryable["action"]; next: number }> =
[]
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Rate limit exceeded",
isRetryable: true,
statusCode: 429,
responseHeaders: { "retry-after": "30000" },
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" },
}),
}).toObject(),
)

const before = yield* Clock.currentTimeMillis
yield* Effect.fail(error).pipe(
Effect.retry(
SessionRetry.policy({
provider: "opencode",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
updates.push(info)
}),
}),
),
Effect.catch(() => Effect.void),
)
const after = yield* Clock.currentTimeMillis

expect(updates).toHaveLength(1)
expect(updates[0]?.message).toBe(SessionRetry.GO_UPSELL_MESSAGE)
expect(updates[0]?.action?.reason).toBe("free_tier_limit")
expect(updates[0]?.next).toBeGreaterThanOrEqual(before)
expect(updates[0]?.next).toBeLessThanOrEqual(after)
expect(after - before).toBeLessThan(1000)
}),
)

it.instance("policy surfaces Go usage limit once then stops without waiting", () =>
Effect.gen(function* () {
const updates: Array<{ attempt: number; message: string; action?: SessionRetry.Retryable["action"] }> = []
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Subscription quota exceeded",
isRetryable: true,
statusCode: 429,
responseHeaders: { "retry-after": "19380" },
responseBody: JSON.stringify({
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
limitName: "5 hour",
},
}),
}).toObject(),
)

const before = yield* Clock.currentTimeMillis
yield* Effect.fail(error).pipe(
Effect.retry(
SessionRetry.policy({
provider: "opencode-go",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
updates.push(info)
}),
}),
),
Effect.catch(() => Effect.void),
)
const after = yield* Clock.currentTimeMillis

expect(updates).toHaveLength(1)
expect(updates[0]?.action?.reason).toBe("account_rate_limit")
expect(after - before).toBeLessThan(1000)
}),
)

it.instance("policy still retries transient rate limits", () =>
Effect.gen(function* () {
const attempts: number[] = []
const error = apiError({ "retry-after-ms": "0" })
yield* Effect.fail(error).pipe(
Effect.retry(
SessionRetry.policy({
provider: "test",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
attempts.push(info.attempt)
}),
}),
),
Effect.catch(() => Effect.void),
)

expect(attempts).toStrictEqual([1, 2, 3, 4, 5])
}),
)
})

describe("session.retry.retryable", () => {
Expand Down
Loading