diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 284c0f0ade41..612a7dff63e4 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -29,6 +29,7 @@ export const RETRY_JITTER_FACTOR = 0.25 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout export const RETRY_MAX_RETRIES = 5 +export const RETRY_MAX_WAIT = 300_000 // longest provider-requested wait worth sleeping through - waits above this stop the retry loop instead of sleeping, see policy(). Distinct from RETRY_MAX_DELAY (backoff cap). const RETRYABLE_MESSAGE_PATTERNS = [ /429|500|502|503|504|524/i, @@ -198,8 +199,12 @@ export function policy(opts: { attempt: meta.attempt, message: retry.message, action: retry.action, - next: now + wait, + next: wait > RETRY_MAX_WAIT ? now : now + wait, }) + // Status is published first so the limit dialog still fires, then we stop: a wait + // this long is an exhausted quota window, and sleeping through it leaves the + // session pending for hours with no visible error. + if (wait > RETRY_MAX_WAIT) return yield* Cause.done(meta.attempt) return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] }) }), diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 20c8678cf0a7..093ea93b69ca 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -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 { Duration, Effect, Exit, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -146,6 +146,51 @@ describe("session.retry.delay", () => { expect(attempts).toStrictEqual([1, 2, 3, 4, 5]) }), ) + + it.instance("policy stops when the provider asks for a wait past the ceiling", () => + Effect.gen(function* () { + const seen: { message: string; reason?: string }[] = [] + // opencode zen answers an exhausted free tier with 429 and a retry-after pointing + // at the next daily reset, so the wait can be most of a day + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message: "Rate limit exceeded. Please try again later.", + statusCode: 429, + isRetryable: true, + responseHeaders: { "retry-after": "43200" }, + responseBody: JSON.stringify({ type: "error", error: { type: "FreeUsageLimitError" } }), + }).toObject(), + ) + const step = yield* Schedule.toStep( + SessionRetry.policy({ + provider: "opencode", + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + seen.push({ message: info.message, reason: info.action?.reason }) + }), + }), + ) + + expect(Exit.isFailure(yield* Effect.exit(step(Date.now(), error)))).toBe(true) + expect(seen).toStrictEqual([{ message: SessionRetry.GO_UPSELL_MESSAGE, reason: "free_tier_limit" }]) + }), + ) + + it.instance("policy keeps retrying a wait within the ceiling", () => + Effect.gen(function* () { + const error = apiError({ "retry-after-ms": String(SessionRetry.RETRY_MAX_WAIT) }) + const step = yield* Schedule.toStep( + SessionRetry.policy({ + provider: "test", + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: () => Effect.void, + }), + ) + + expect(Duration.toMillis((yield* step(Date.now(), error))[1])).toBe(SessionRetry.RETRY_MAX_WAIT) + }), + ) }) describe("session.retry.retryable", () => {