Skip to content
Merged
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: 10 additions & 3 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {

export type Retryable = {
message: string
maxAttempts?: number
// Give up once the schedule's CUMULATIVE attempt count reaches this. One
// counter covers the whole request, so earlier retries of ANY reason spend
// this budget: two rate-limit retries followed by an output-limit error
// leaves the output-limit path none. Reasons that omit it are unbounded.
maxTotalAttempts?: number
action?: {
reason: RetryReason
provider: string
Expand Down Expand Up @@ -78,7 +82,7 @@ export function delay(attempt: number, error?: SessionV1.APIError) {

export function retryable(error: Err, provider: string) {
if (SessionV1.OutputLengthError.isInstance(error)) {
return { message: "Model hit its output limit", maxAttempts: 3 }
return { message: "Model hit its output limit", maxTotalAttempts: 3 }
}
// context overflow errors should not be retried
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
Expand Down Expand Up @@ -198,7 +202,10 @@ export function policy(opts: {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
if (retry.maxAttempts !== undefined && meta.attempt >= retry.maxAttempts) return Cause.done(meta.attempt)
// `meta.attempt` is 1-based and shared by every reason (Schedule keeps one
// counter per request), which is why the cap reads as a total.
if (retry.maxTotalAttempts !== undefined && meta.attempt >= retry.maxTotalAttempts)
return Cause.done(meta.attempt)
return Effect.gen(function* () {
if (opts.onRetry) yield* opts.onRetry(error)
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ describe("session.retry.delay", () => {
expect(Exit.isFailure(third)).toBe(true)
}),
)

it.effect("earlier retries of another reason spend the output-length budget", () =>
Effect.gen(function* () {
// maxTotalAttempts is measured against one per-request counter, so a
// request that already retried twice for rate limits gets no resample.
let current: SessionRetry.Err = new SessionV1.APIError({
message: "boom",
isRetryable: true,
responseHeaders: { "retry-after-ms": "0" },
}).toObject()
const step = yield* Schedule.toStep(
SessionRetry.policy({
provider: "test",
parse: () => current,
set: () => Effect.void,
}),
)

yield* step(0, current)
yield* step(0, current)
current = new SessionV1.OutputLengthError({}).toObject()
expect(Exit.isFailure(yield* step(0, current).pipe(Effect.exit))).toBe(true)
}),
)
})

describe("session.retry.retryable", () => {
Expand Down
Loading