From d20be3ab54da1ab9045324c4752ae8b716a5718f Mon Sep 17 00:00:00 2001 From: Gianluca Meneghetti Date: Sun, 6 Sep 2026 18:14:47 +0200 Subject: [PATCH 1/2] fix(opencode): stop retrying when the provider asks to wait for hours delay() honors retry-after up to RETRY_MAX_DELAY (~24.8 days), so an exhausted quota window parks the session on a single attempt that can be a full day out. Give up past RETRY_MAX_WAIT instead, after publishing the retry status so the limit dialog still fires. --- packages/opencode/src/session/retry.ts | 5 +++ packages/opencode/test/session/retry.test.ts | 47 +++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 284c0f0ade41..bdaa01a5e79a 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 const RETRYABLE_MESSAGE_PATTERNS = [ /429|500|502|503|504|524/i, @@ -200,6 +201,10 @@ export function policy(opts: { action: retry.action, next: 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", () => { From f25844076347e20bab70b05f285a406daeba990c Mon Sep 17 00:00:00 2001 From: Gianluca Meneghetti Date: Tue, 8 Sep 2026 15:17:14 +0200 Subject: [PATCH 2/2] fix(opencode): clamp published retry next and clarify RETRY_MAX_WAIT --- packages/opencode/src/session/retry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index bdaa01a5e79a..612a7dff63e4 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -29,7 +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 +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, @@ -199,7 +199,7 @@ 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