From 76a7a6c2336f6cca001e1462807e82c52c94b97d Mon Sep 17 00:00:00 2001 From: Alexander Yue Date: Mon, 3 Aug 2026 10:45:28 -0700 Subject: [PATCH] refactor(opencode): name the retry cap for its cumulative scope maxAttempts sits on the per-reason Retryable descriptor but is compared against Schedule's per-request attempt counter, which every retry reason shares. The name reads as a per-reason budget, so rename it to maxTotalAttempts and document the sharing at both sites. No behavior change. Adds the mixed-reason test the suite was missing: two rate-limit retries leave the output-limit path no resample. --- packages/opencode/src/session/retry.ts | 13 ++++++++--- packages/opencode/test/session/retry.test.ts | 24 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 8d47b7330..b10e6ad5c 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -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 @@ -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 @@ -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) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index cbf43c459..24d2dd728 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -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", () => {