From a1da3bd26c89aafa043ddcdcabadc47c08153b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JoaoBern=C3=A9?= <271995884+JoaoBerne@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:44:56 +0200 Subject: [PATCH 1/4] feat: make the session retry policy configurable RETRY_MAX_RETRIES and the backoff constants in session/retry.ts are compiled in, so a turn is abandoned after 5 attempts (~68s) with no way to change it. That default is right for the infinite-loop reports it was added for, and wrong for providers whose transient errors outlive it. Add experimental.retry to the config schema and thread it into SessionRetry.policy. Every field falls back to the existing constant, so the schedule is byte-for-byte unchanged when the key is absent. maxRetries of -1 retries for as long as the error stays retryable. Expose backoffFactor alongside maxRetries: with the factor left at 2, a raised attempt count is mostly dead time, since an error carrying headers but no retry-after is not clamped by RETRY_MAX_DELAY_NO_HEADERS at all. Refs #43596 Assisted-by: Claude Opus 5 --- packages/core/src/v1/config/config.ts | 28 ++++++++ packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/retry.ts | 62 ++++++++++++---- packages/opencode/test/session/retry.test.ts | 74 ++++++++++++++++++++ 4 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..077e8628fa31 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -185,6 +185,34 @@ export const Info = Schema.Struct({ policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ description: "Policy statements applied to supported resources, such as provider access", }), + retry: Schema.optional( + Schema.Struct({ + maxRetries: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1))).annotate({ + description: + "Maximum retry attempts for a retryable provider error. Use -1 to keep retrying for as long as the error stays retryable (default: 5)", + }), + initialDelayMs: Schema.optional(PositiveInt).annotate({ + description: "Delay before the first retry, in milliseconds (default: 2000)", + }), + backoffFactor: Schema.optional(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(1))).annotate({ + description: + "Multiplier applied to the delay after each attempt. 1 keeps the delay flat instead of doubling (default: 2)", + }), + jitterFactor: Schema.optional(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))).annotate({ + description: "Fraction of the delay added back as random jitter (default: 0.25)", + }), + maxDelayMs: Schema.optional(PositiveInt).annotate({ + description: "Absolute ceiling on any retry delay, in milliseconds (default: 2147483647)", + }), + maxDelayNoHeadersMs: Schema.optional(PositiveInt).annotate({ + description: + "Ceiling on the backoff delay when the error carries no retry-after headers, in milliseconds (default: 30000)", + }), + }), + ).annotate({ + description: + "Retry policy for retryable provider errors. Omitted fields keep their defaults, so the schedule is unchanged unless you set them.", + }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 9f8530929c15..e62e7e774e97 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -674,6 +674,7 @@ const layer = Layer.effect( Effect.retry( SessionRetry.policy({ provider: input.model.providerID, + tuning: (yield* config.get()).experimental?.retry, parse, set: (info) => { return status.set(ctx.sessionID, { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 284c0f0ade41..566640717511 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -30,6 +30,33 @@ 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 +/** + * Overrides for the retry schedule, supplied by `experimental.retry` in the + * config. Every field falls back to the constant above, so an absent or empty + * object reproduces the default schedule exactly. + */ +export type Tuning = { + maxRetries?: number + initialDelayMs?: number + backoffFactor?: number + jitterFactor?: number + maxDelayMs?: number + maxDelayNoHeadersMs?: number +} + +function resolve(tuning?: Tuning) { + return { + maxRetries: tuning?.maxRetries ?? RETRY_MAX_RETRIES, + initialDelayMs: tuning?.initialDelayMs ?? RETRY_INITIAL_DELAY, + backoffFactor: tuning?.backoffFactor ?? RETRY_BACKOFF_FACTOR, + jitterFactor: tuning?.jitterFactor ?? RETRY_JITTER_FACTOR, + maxDelayMs: tuning?.maxDelayMs ?? RETRY_MAX_DELAY, + maxDelayNoHeadersMs: tuning?.maxDelayNoHeadersMs ?? RETRY_MAX_DELAY_NO_HEADERS, + } +} + +type Limits = ReturnType + const RETRYABLE_MESSAGE_PATTERNS = [ /429|500|502|503|504|524/i, /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, @@ -40,11 +67,12 @@ const RETRYABLE_MESSAGE_PATTERNS = [ /\btry again (?:later|in\b)|\b(?:currently|temporarily) at capacity\b/i, ] -function cap(ms: number) { - return Math.min(ms, RETRY_MAX_DELAY) +function cap(ms: number, limits: Limits) { + return Math.min(ms, limits.maxDelayMs) } -export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random()) { +export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random(), tuning?: Tuning) { + const limits = resolve(tuning) if (error) { const headers = error.data.responseHeaders if (headers) { @@ -52,7 +80,7 @@ export function delay(attempt: number, error?: SessionV1.APIError, random = Math if (retryAfterMs) { const parsedMs = Number.parseFloat(retryAfterMs) if (!Number.isNaN(parsedMs)) { - return cap(parsedMs) + return cap(parsedMs, limits) } } @@ -61,25 +89,25 @@ export function delay(attempt: number, error?: SessionV1.APIError, random = Math const parsedSeconds = Number.parseFloat(retryAfter) if (!Number.isNaN(parsedSeconds)) { // convert seconds to milliseconds - return cap(Math.ceil(parsedSeconds * 1000)) + return cap(Math.ceil(parsedSeconds * 1000), limits) } // Try parsing as HTTP date format const parsed = Date.parse(retryAfter) - Date.now() if (!Number.isNaN(parsed) && parsed > 0) { - return cap(Math.ceil(parsed)) + return cap(Math.ceil(parsed), limits) } } - return cap(exponential(attempt, random)) + return cap(exponential(attempt, random, limits), limits) } } - return cap(Math.min(exponential(attempt, random), RETRY_MAX_DELAY_NO_HEADERS)) + return cap(Math.min(exponential(attempt, random, limits), limits.maxDelayNoHeadersMs), limits) } -function exponential(attempt: number, random: number) { - const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) - return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) +function exponential(attempt: number, random: number, limits: Limits) { + const base = limits.initialDelayMs * Math.pow(limits.backoffFactor, attempt - 1) + return Math.ceil(base + base * limits.jitterFactor * random) } export function retryable(error: Err, provider: string) { @@ -182,17 +210,25 @@ function parseJSON(value: unknown) { export function policy(opts: { provider: string + tuning?: Tuning parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { + const limits = resolve(opts.tuning) return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) - if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt) + // A negative limit means retry for as long as the error stays retryable. + if (limits.maxRetries >= 0 && meta.attempt > limits.maxRetries) return Cause.done(meta.attempt) return Effect.gen(function* () { - const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) + const wait = delay( + meta.attempt, + SessionV1.APIError.isInstance(error) ? error : undefined, + Math.random(), + opts.tuning, + ) const now = yield* Clock.currentTimeMillis yield* opts.set({ attempt: meta.attempt, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 20c8678cf0a7..28482fb11e58 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -146,6 +146,80 @@ describe("session.retry.delay", () => { expect(attempts).toStrictEqual([1, 2, 3, 4, 5]) }), ) + + test("keeps the default schedule when tuning is absent or empty", () => { + const error = apiError() + const base = Array.from({ length: 6 }, (_, index) => SessionRetry.delay(index + 1, error, 0)) + const empty = Array.from({ length: 6 }, (_, index) => SessionRetry.delay(index + 1, error, 0, {})) + expect(base).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000]) + expect(empty).toStrictEqual(base) + }) + + test("flattens the schedule when backoffFactor is 1", () => { + const error = apiError() + const delays = Array.from({ length: 5 }, (_, index) => + SessionRetry.delay(index + 1, error, 0, { initialDelayMs: 500, backoffFactor: 1 }), + ) + expect(delays).toStrictEqual([500, 500, 500, 500, 500]) + }) + + test("honours a lower ceiling when headers are missing", () => { + const error = apiError() + const delays = Array.from({ length: 4 }, (_, index) => + SessionRetry.delay(index + 1, error, 0, { maxDelayNoHeadersMs: 2000 }), + ) + expect(delays).toStrictEqual([2000, 2000, 2000, 2000]) + }) + + test("honours a lower absolute ceiling for header delays", () => { + const error = apiError({ "retry-after": "600" }) + expect(SessionRetry.delay(1, error, 0, { maxDelayMs: 60_000 })).toBe(60_000) + }) + + it.instance("policy stops after a configured maxRetries", () => + Effect.gen(function* () { + const attempts: number[] = [] + const error = apiError({ "retry-after-ms": "0" }) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + tuning: { maxRetries: 2 }, + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + attempts.push(info.attempt) + }), + }), + ) + + yield* Effect.forEach(Array.from({ length: 5 }), () => Effect.ignore(step(error))) + + expect(attempts).toStrictEqual([1, 2]) + }), + ) + + it.instance("policy keeps retrying when maxRetries is negative", () => + Effect.gen(function* () { + const attempts: number[] = [] + const error = apiError({ "retry-after-ms": "0" }) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + tuning: { maxRetries: -1 }, + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + attempts.push(info.attempt) + }), + }), + ) + + yield* Effect.forEach(Array.from({ length: 20 }), () => Effect.ignore(step(error))) + + expect(attempts).toHaveLength(20) + expect(attempts.at(-1)).toBe(20) + }), + ) }) describe("session.retry.retryable", () => { From f06e5e6daa20898fab8659bb643de98aa9ac83f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JoaoBern=C3=A9?= <271995884+JoaoBerne@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:14:12 +0200 Subject: [PATCH 2/4] fix: keep a tuned retry delay finite, non-negative and representable Making the constants configurable opened three ways to produce a delay the scheduler cannot honour. Duration.millis turns every one of them into an immediate retry, which is the failure mode the attempt cap was added for. 1. maxDelayMs above 2^31-1. That value was the constant precisely because setTimeout overflows past it: delay() -> 4294967296 TimeoutOverflowWarning: does not fit into a 32-bit signed integer slept 2ms, expected ~49 days Bounded in the schema, and normalized in resolve() as well, since the plugin config hook mutates the loaded config without revalidation. 2. NaN. A large backoffFactor or jitterFactor overflows the exponential to Infinity, and Infinity * 0 jitter is NaN. Reachable at attempt 1 with a jitterFactor the schema accepts, and at attempt ~320 with backoffFactor 10 once maxRetries is -1. 3. Negative. A malformed retry-after already produced a negative delay; the HTTP-date branch guards for it, the two numeric branches did not. Bounded attempts kept this cheap, -1 does not. cap() is the single choke point every return path in delay() goes through, so the finite and non-negative guards live there. Default schedules are unchanged, pinned by the existing and new tests. Assisted-by: Claude Opus 5 --- packages/core/src/v1/config/config.ts | 5 ++-- packages/opencode/src/session/retry.ts | 12 ++++++-- packages/opencode/test/session/retry.test.ts | 30 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 077e8628fa31..71c919eebdac 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -201,8 +201,9 @@ export const Info = Schema.Struct({ jitterFactor: Schema.optional(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))).annotate({ description: "Fraction of the delay added back as random jitter (default: 0.25)", }), - maxDelayMs: Schema.optional(PositiveInt).annotate({ - description: "Absolute ceiling on any retry delay, in milliseconds (default: 2147483647)", + maxDelayMs: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(2_147_483_647))).annotate({ + description: + "Absolute ceiling on any retry delay, in milliseconds. Cannot exceed 2147483647, the largest value setTimeout can represent (default: 2147483647)", }), maxDelayNoHeadersMs: Schema.optional(PositiveInt).annotate({ description: diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 566640717511..610ae75836a4 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -50,7 +50,10 @@ function resolve(tuning?: Tuning) { initialDelayMs: tuning?.initialDelayMs ?? RETRY_INITIAL_DELAY, backoffFactor: tuning?.backoffFactor ?? RETRY_BACKOFF_FACTOR, jitterFactor: tuning?.jitterFactor ?? RETRY_JITTER_FACTOR, - maxDelayMs: tuning?.maxDelayMs ?? RETRY_MAX_DELAY, + // Normalized here so policy() and delay() read the same ceiling. Config + // validation also bounds this, but a plugin config hook mutates the loaded + // config without revalidation, so it cannot be the only guard. + maxDelayMs: Math.min(tuning?.maxDelayMs ?? RETRY_MAX_DELAY, RETRY_MAX_DELAY), maxDelayNoHeadersMs: tuning?.maxDelayNoHeadersMs ?? RETRY_MAX_DELAY_NO_HEADERS, } } @@ -68,7 +71,12 @@ const RETRYABLE_MESSAGE_PATTERNS = [ ] function cap(ms: number, limits: Limits) { - return Math.min(ms, limits.maxDelayMs) + // A tuned backoffFactor or jitterFactor can overflow the exponential to + // Infinity, and Infinity * 0 jitter is NaN. Duration.millis turns NaN and + // negatives alike into an immediate retry, so every delay is pinned to a + // finite, non-negative value here rather than at each return site. + if (!Number.isFinite(ms)) return limits.maxDelayMs + return Math.min(Math.max(ms, 0), limits.maxDelayMs) } export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random(), tuning?: Tuning) { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 28482fb11e58..79e66f6a579c 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -176,6 +176,36 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error, 0, { maxDelayMs: 60_000 })).toBe(60_000) }) + test("clamps a maxDelayMs that setTimeout cannot represent", () => { + const tuning = { maxDelayMs: 4_294_967_296, initialDelayMs: 4_294_967_296, maxDelayNoHeadersMs: 4_294_967_296 } + expect(SessionRetry.delay(1, undefined, 0, tuning)).toBe(SessionRetry.RETRY_MAX_DELAY) + expect(SessionRetry.delay(1, apiError({ "retry-after-ms": "4294967296" }), 0, tuning)).toBe( + SessionRetry.RETRY_MAX_DELAY, + ) + }) + + test("never returns a non-finite delay when the exponential overflows", () => { + // base * jitter reaches Infinity, and Infinity * 0 jitter is NaN + expect(SessionRetry.delay(1, undefined, 0, { jitterFactor: 1e306 })).toBe(SessionRetry.RETRY_MAX_DELAY) + expect(SessionRetry.delay(320, undefined, 0, { backoffFactor: 10 })).toBe(SessionRetry.RETRY_MAX_DELAY) + expect(SessionRetry.delay(10, undefined, 0, { initialDelayMs: 1e300, backoffFactor: 10 })).toBe( + SessionRetry.RETRY_MAX_DELAY, + ) + }) + + test("never returns a negative delay from a malformed retry-after", () => { + expect(SessionRetry.delay(1, apiError({ "retry-after-ms": "-5000" }), 0)).toBe(0) + expect(SessionRetry.delay(1, apiError({ "retry-after": "-100" }), 0)).toBe(0) + }) + + test("pins the jitter ceiling for a tuned schedule", () => { + const error = apiError() + const tuning = { initialDelayMs: 500, backoffFactor: 1, jitterFactor: 0.2 } + expect(SessionRetry.delay(1, error, 0, tuning)).toBe(500) + expect(SessionRetry.delay(1, error, 1, tuning)).toBe(600) + expect(SessionRetry.delay(9, error, 1, tuning)).toBe(600) + }) + it.instance("policy stops after a configured maxRetries", () => Effect.gen(function* () { const attempts: number[] = [] From c575723528d5ba9e5be5e77d62b3fda5cfb092d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JoaoBern=C3=A9?= <271995884+JoaoBerne@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:58:25 +0200 Subject: [PATCH 3/4] test: pin that maxRetries 0 disables the session-level replay #43596 now also asks for the opposite of what motivated this: turns with side effects need the processor to never replay a failed provider request. Zero already did that, since the policy returns done on attempt 1, but nothing pinned it. Assisted-by: Claude Opus 5.5 --- packages/opencode/test/session/retry.test.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 79e66f6a579c..c678a5b5fead 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -228,6 +228,29 @@ describe("session.retry.delay", () => { }), ) + it.instance("policy never retries when maxRetries is zero", () => + Effect.gen(function* () { + const attempts: number[] = [] + const error = apiError({ "retry-after-ms": "0" }) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + tuning: { maxRetries: 0 }, + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + attempts.push(info.attempt) + }), + }), + ) + + yield* Effect.forEach(Array.from({ length: 5 }), () => Effect.ignore(step(error))) + + // with side effects in play, a failed request must never be replayed + expect(attempts).toStrictEqual([]) + }), + ) + it.instance("policy keeps retrying when maxRetries is negative", () => Effect.gen(function* () { const attempts: number[] = [] From fca21c174dc5a015f4530c84b95da186149210ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JoaoBern=C3=A9?= <271995884+JoaoBerne@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:52:01 +0200 Subject: [PATCH 4/4] fix: validate every tuned value where it is read, not just maxDelayMs's ceiling The plugin config hook mutates the loaded config without revalidation, so schema bounds cannot be the only guard. resolve() only enforced an upper bound on maxDelayMs: a hook setting it to -1 or NaN made cap() return a negative or NaN delay, which Duration.millis turns into an immediate retry (Copilot review). The same exposure applied to every other field, and one was worse: a NaN maxRetries failed the `>= 0` test and read as unlimited. Each field now falls back to its default unless it passes the same rule as the schema. Generated SDK and openapi.json are left to generate.yml, which regenerates them on every push to dev. Assisted-by: Claude Opus 5.5 --- packages/opencode/src/session/retry.ts | 22 ++++++++------ packages/opencode/test/session/retry.test.ts | 32 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 610ae75836a4..9cb0e29f37ec 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -45,16 +45,20 @@ export type Tuning = { } function resolve(tuning?: Tuning) { + // Config validation already enforces these rules, but a plugin config hook + // mutates the loaded config without revalidation, so they are applied again + // here, where every caller reads them. Anything invalid falls back to the + // default: a NaN maxRetries would otherwise mean unlimited, and a negative or + // NaN maxDelayMs would slip through cap(). + const pick = (value: number | undefined, fallback: number, valid: (n: number) => boolean) => + value !== undefined && Number.isFinite(value) && valid(value) ? value : fallback return { - maxRetries: tuning?.maxRetries ?? RETRY_MAX_RETRIES, - initialDelayMs: tuning?.initialDelayMs ?? RETRY_INITIAL_DELAY, - backoffFactor: tuning?.backoffFactor ?? RETRY_BACKOFF_FACTOR, - jitterFactor: tuning?.jitterFactor ?? RETRY_JITTER_FACTOR, - // Normalized here so policy() and delay() read the same ceiling. Config - // validation also bounds this, but a plugin config hook mutates the loaded - // config without revalidation, so it cannot be the only guard. - maxDelayMs: Math.min(tuning?.maxDelayMs ?? RETRY_MAX_DELAY, RETRY_MAX_DELAY), - maxDelayNoHeadersMs: tuning?.maxDelayNoHeadersMs ?? RETRY_MAX_DELAY_NO_HEADERS, + maxRetries: pick(tuning?.maxRetries, RETRY_MAX_RETRIES, (n) => Number.isInteger(n) && n >= -1), + initialDelayMs: pick(tuning?.initialDelayMs, RETRY_INITIAL_DELAY, (n) => n > 0), + backoffFactor: pick(tuning?.backoffFactor, RETRY_BACKOFF_FACTOR, (n) => n >= 1), + jitterFactor: pick(tuning?.jitterFactor, RETRY_JITTER_FACTOR, (n) => n >= 0), + maxDelayMs: pick(tuning?.maxDelayMs, RETRY_MAX_DELAY, (n) => n > 0 && n <= RETRY_MAX_DELAY), + maxDelayNoHeadersMs: pick(tuning?.maxDelayNoHeadersMs, RETRY_MAX_DELAY_NO_HEADERS, (n) => n > 0), } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index c678a5b5fead..10e4a8adfacd 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -198,6 +198,16 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, apiError({ "retry-after": "-100" }), 0)).toBe(0) }) + test("falls back to defaults for values a plugin slipped past validation", () => { + // the plugin config hook mutates the loaded config without revalidating it + const error = apiError() + expect(SessionRetry.delay(1, error, 0, { maxDelayMs: -1 })).toBe(2000) + expect(SessionRetry.delay(1, error, 0, { maxDelayMs: NaN })).toBe(2000) + expect(SessionRetry.delay(1, error, 0, { initialDelayMs: -100 })).toBe(2000) + expect(SessionRetry.delay(1, error, 0, { backoffFactor: NaN })).toBe(2000) + expect(SessionRetry.delay(2, error, 0, { backoffFactor: 0.5 })).toBe(4000) + }) + test("pins the jitter ceiling for a tuned schedule", () => { const error = apiError() const tuning = { initialDelayMs: 500, backoffFactor: 1, jitterFactor: 0.2 } @@ -251,6 +261,28 @@ describe("session.retry.delay", () => { }), ) + it.instance("policy treats an invalid maxRetries as the default, not as unlimited", () => + Effect.gen(function* () { + const attempts: number[] = [] + const error = apiError({ "retry-after-ms": "0" }) + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + tuning: { maxRetries: NaN }, + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + set: (info) => + Effect.sync(() => { + attempts.push(info.attempt) + }), + }), + ) + + yield* Effect.forEach(Array.from({ length: 10 }), () => Effect.ignore(step(error))) + + expect(attempts).toStrictEqual([1, 2, 3, 4, 5]) + }), + ) + it.instance("policy keeps retrying when maxRetries is negative", () => Effect.gen(function* () { const attempts: number[] = []