Skip to content
Open
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
7 changes: 6 additions & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
})
}),
Expand Down
47 changes: 46 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 { 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"
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading