feat(core): typed free-limit error with reset time (#57146)

This commit is contained in:
Kit Langton
2026-10-20 15:10:35 -07:00
committed by GitHub
Unverified
parent db1fbe849c
commit a9fa57b738
8 changed files with 86 additions and 7 deletions
+1 -1
View File
@@ -263,7 +263,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
return new QuotaExceededError({ ...details, retryAfterMs: input.retryAfterMs })
if (
input.status === 401 ||
input.status === 403 ||
+4 -1
View File
@@ -83,7 +83,10 @@ export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error
export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
{
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
},
) {}
export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
+5
View File
@@ -685,6 +685,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
}
}
@@ -852,6 +853,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
@@ -1040,6 +1042,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
readonly content?:
| readonly [
@@ -1087,6 +1090,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
}
}
@@ -1148,6 +1152,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
readonly inputID?: SessionMessage.ID | undefined
readonly cost?: Money.USD | undefined
@@ -108,7 +108,13 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number; response?: { body: string } }
export type SessionStructuredError = {
type: string
message: string
status?: number
response?: { body: string }
retryAfterMs?: number
}
export type SessionActive = { type: "running" }
+14 -2
View File
@@ -25,6 +25,9 @@ const tokenSharingMessages = {
"This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.",
}
const FREE_USAGE_LIMIT_MESSAGE =
"Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models."
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AIError) {
switch (cause.reason._tag) {
@@ -32,8 +35,17 @@ export function toSessionError(cause: unknown): SessionError.Error {
return providerError("provider.rate-limit", cause.reason)
case "Authentication":
return providerError("provider.auth", cause.reason)
case "QuotaExceeded":
return providerError("provider.quota", cause.reason)
case "QuotaExceeded": {
if (!cause.reason.body?.includes("FreeUsageLimitError")) return providerError("provider.quota", cause.reason)
const retryAfterMs = cause.reason.retryAfterMs
return {
...providerError("provider.free-limit", cause.reason),
message: FREE_USAGE_LIMIT_MESSAGE,
...(retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs >= 0
? { retryAfterMs: Math.ceil(retryAfterMs) }
: {}),
}
}
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
case "Transport":
+50
View File
@@ -17,6 +17,7 @@ import {
ToolFailure,
HttpContext,
} from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { Permission } from "@opencode/core/permission"
import { ID } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
@@ -71,6 +72,55 @@ describe("toSessionError", () => {
expect(toSessionError(llm(new UnknownProviderError({ message: "unknown" }))).type).toBe("provider.unknown")
})
test("separates the free usage limit from paid rate limits, Go quota, and other providers' 429s", () => {
const fail = (url: string, body: unknown, headers?: Record<string, string>) =>
RequestExecutor.httpFailure({
message: "Rate limit exceeded. Please try again later.",
url,
status: 429,
responseHeaders: headers,
responseBody: JSON.stringify(body),
})
const freeBody = {
type: "error",
error: { type: "FreeUsageLimitError", message: "Rate limit exceeded. Please try again later." },
}
const free = fail("https://opencode.ai/inference/openai/v1/chat/completions", freeBody, { "retry-after": "3600" })
expect(SessionRunnerRetry.isRetryable(free)).toBe(false)
expect(toSessionError(free)).toEqual({
type: "provider.free-limit",
message: "Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models.",
status: 429,
response: { body: JSON.stringify(freeBody) },
retryAfterMs: 3_600_000,
})
const paid = fail(
"https://opencode.ai/inference/openai/v1/chat/completions",
{ error: { type: "server_error", message: "Upstream request failed: Rate limit exceeded." } },
{ "retry-after": "45" },
)
expect(SessionRunnerRetry.isRetryable(paid)).toBe(true)
expect(toSessionError(paid).type).toBe("provider.rate-limit")
const go = fail(
"https://opencode.ai/inference/go/openai/v1/chat/completions",
{ type: "error", error: { type: "GoUsageLimitError", message: "Subscription quota exceeded." } },
{ "retry-after": "120" },
)
expect(SessionRunnerRetry.isRetryable(go)).toBe(false)
expect(toSessionError(go).type).toBe("provider.quota")
const other = fail(
"https://api.openai.com/v1/chat/completions",
{ error: { type: "rate_limit_error", message: "Too many requests" } },
{ "retry-after": "10" },
)
expect(SessionRunnerRetry.isRetryable(other)).toBe(true)
expect(toSessionError(other).type).toBe("provider.rate-limit")
})
test("preserves the permission rejection type without exposing internal fields", () => {
const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] })
expect(toSessionError(blocked)).toEqual({
+3 -1
View File
@@ -1,7 +1,7 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { NonNegativeInt, optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
@@ -9,4 +9,6 @@ export const Error = Schema.Struct({
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
response: Schema.Struct({ body: Schema.String }).pipe(optional),
/** How long the provider asked to wait before trying again, counted from when the failure was recorded. */
retryAfterMs: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
+2 -1
View File
@@ -13,6 +13,7 @@ describe("SessionError", () => {
{ type: "provider.rate-limit", message: "Slow down" },
{ type: "provider.rate-limit", message: "Slow down", status: 429, response: { body: '{"error":{}}' } },
{ type: "provider.auth", message: "Authentication failed" },
{ type: "provider.free-limit", message: "Free usage limit reached", status: 429, retryAfterMs: 3_600_000 },
{ type: "provider.future-condition", message: "A future provider failure" },
{ type: "unknown", message: "Unexpected" },
]
@@ -29,7 +30,7 @@ describe("SessionError", () => {
Schema.decodeUnknownSync(SessionError.Error)({
type: "provider.timeout",
message: "Timeout",
retryAfterMs: 2_500,
retryable: true,
}),
).toEqual({ type: "provider.timeout", message: "Timeout" })
})