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
13 changes: 12 additions & 1 deletion packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
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_ATTEMPTS = 3
// Hard quota limits (e.g. Go weekly/monthly) send retry-after of hours or days.
// Sleeping that long inside a session is indistinguishable from a hang, so cap
// every individual wait: worst case the schedule burns its remaining attempts
// in minutes and surfaces session.error instead of slumbering.
export const RETRY_MAX_WAIT = 60_000 // 1 minute

function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
Expand Down Expand Up @@ -177,14 +183,19 @@ export function policy(opts: {
provider: string
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
maxAttempts?: number
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
if (meta.attempt > (opts.maxAttempts ?? RETRY_MAX_ATTEMPTS)) return Cause.done(meta.attempt)
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
const wait = Math.min(
delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined),
RETRY_MAX_WAIT,
)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({
attempt: meta.attempt,
Expand Down
78 changes: 77 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, Fiber, 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 @@ -117,6 +117,82 @@ describe("session.retry.delay", () => {
)
})

describe("session.retry.policy.limits", () => {
function policyStep(seen: { count: number; next?: number }, maxAttempts?: number) {
return Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
seen.count++
seen.next = info.next
}),
maxAttempts,
}),
)
}

// the schedule step sleeps its computed wait, so for long waits we fork the
// step, assert the published status, and interrupt instead of sleeping
function firstWait(seen: { count: number; next?: number }, error: SessionV1.APIError) {
return Effect.gen(function* () {
const step = yield* policyStep(seen)
const before = Date.now()
const fiber = yield* Effect.forkChild(step(error))
yield* Effect.promise(() => sleep(100))
yield* Fiber.interrupt(fiber)
expect(seen.count).toBe(1)
return seen.next! - before
})
}

it.instance("stops after RETRY_MAX_ATTEMPTS", () =>
Effect.gen(function* () {
const error = apiError({ "retry-after-ms": "0" })
const seen = { count: 0 }
const step = yield* policyStep(seen)
// steps beyond the cap terminate the schedule (Cause.done surfaces as a
// failure in the step Effect), so absorb with Effect.ignore
for (let i = 0; i < SessionRetry.RETRY_MAX_ATTEMPTS + 2; i++) {
yield* Effect.ignore(step(error))
}
expect(seen.count).toBe(SessionRetry.RETRY_MAX_ATTEMPTS)
}),
)

it.instance("maxAttempts option overrides the default cap", () =>
Effect.gen(function* () {
const error = apiError({ "retry-after-ms": "0" })
const seen = { count: 0 }
const step = yield* policyStep(seen, 1)
for (let i = 0; i < 3; i++) {
yield* Effect.ignore(step(error))
}
expect(seen.count).toBe(1)
}),
)

it.instance("caps individual waits at RETRY_MAX_WAIT", () =>
Effect.gen(function* () {
// hard quota limits send retry-after of hours; never slumber the session
const seen = { count: 0 }
const wait = yield* firstWait(seen, apiError({ "retry-after": "21600" })) // 6 hours
expect(wait).toBeLessThanOrEqual(SessionRetry.RETRY_MAX_WAIT + 1000)
expect(wait).toBeGreaterThan(SessionRetry.RETRY_MAX_WAIT - 5000)
}),
)

it.instance("legitimate retry-after just above the cap is not aborted", () =>
Effect.gen(function* () {
const seen = { count: 0 }
const wait = yield* firstWait(seen, apiError({ "retry-after": "61" }))
// capped to RETRY_MAX_WAIT, still retried (status update published)
expect(wait).toBeLessThanOrEqual(SessionRetry.RETRY_MAX_WAIT + 1000)
}),
)
})

describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
Expand Down