Repository navigation
Retry backoff is uncapped when a retryable error has response headers but no retry-after #33728
Description
Description
Retry backoff is uncapped when a retryable error has response headers but no retry-after
Summary
SessionRetry.delay() in packages/opencode/src/session/retry.ts only applies the 30-second RETRY_MAX_DELAY_NO_HEADERS cap in the branch that handles errors with no response headers. When a retryable error carries any responseHeaders object (the common case — every real 502/503 from every provider sends content-type, date, server, etc.) but the headers do not include a parseable retry-after / retry-after-ms, the fallback path uses uncapped exponential backoff bounded only by RETRY_MAX_DELAY (2,147,483,647 ms ≈ 24 days, the setTimeout limit).
The result is that a single flaky 5xx from a provider can leave a session stuck retrying for hours or days, with a "retrying in N seconds" countdown that keeps doubling.
Reproduction
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Schema } from "effect"
import { SessionRetry } from "../../src/session/retry"
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Bad gateway",
isRetryable: true,
statusCode: 502,
responseHeaders: { "content-type": "application/json" }, // any header triggers the bug
}).toObject(),
)
console.log(SessionRetry.delay(10, error)) // → 1_024_000 (~17 minutes)
console.log(SessionRetry.delay(15, error)) // → 32_768_000 (~9 hours)
console.log(SessionRetry.delay(20, error)) // → 1_048_576_000 (~12 days)For comparison, the same error without responseHeaders correctly returns 30_000 for all of these.
Expected behavior
RETRY_MAX_DELAY_NO_HEADERS (30 s) should cap exponential backoff whenever no usable retry-after hint is present — regardless of whether other response headers exist. Response headers like content-type carry no retry signal and should not disable the cap.
Actual behavior
The cap is bypassed whenever error.data.responseHeaders is truthy. Since real HTTP responses almost always include headers, the cap is effectively a dead letter in production.
Environment
- OpenCode commit:
4b83f5d(branchdev) - File:
packages/opencode/src/session/retry.ts, functiondelay, lines 35–66 - Bun: 1.3.14
Suggested fix
Apply Math.min(…, RETRY_MAX_DELAY_NO_HEADERS) to the headers-branch fallback (line 61) — the same expression already used at line 65 for the no-headers branch.
- return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
+ return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))Why the existing test missed it
test/session/retry.test.ts:36 is named "caps delay at 30 seconds when headers missing" and exercises apiError() (no headers). The two related tests that do pass headers (ignores invalid retry hints, ignores malformed date retry hints) only call delay(1, …), which returns 2 s either way and never exposes the missing cap on later attempts.
Plugins
None
OpenCode version
1.17.10 (dev branch, commit 4b83f5d)
Steps to reproduce
SessionRetry.delay() in packages/opencode/src/session/retry.ts only applies the 30-second RETRY_MAX_DELAY_NO_HEADERS cap in the branch that handles errors with no response headers. When a retryable error carries any responseHeaders object (the common case — every real 502/503 from every provider sends content-type, date, server, etc.) but the headers do not include a parseable retry-after or retry-after-ms, the fallback path uses uncapped exponential backoff bounded only by RETRY_MAX_DELAY (2,147,483,647 ms, roughly 24 days, the setTimeout limit).
The result is that a single flaky 5xx from a provider can leave a session stuck retrying for hours or days, with a "retrying in N seconds" countdown that keeps doubling.
Reproduction:
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Schema } from "effect"
import { SessionRetry } from "../../src/session/retry"
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
new SessionV1.APIError({
message: "Bad gateway",
isRetryable: true,
statusCode: 502,
responseHeaders: { "content-type": "application/json" }, // any header triggers the bug
}).toObject(),
)
console.log(SessionRetry.delay(10, error)) // 1,024,000 ms (~17 minutes)
console.log(SessionRetry.delay(15, error)) // 32,768,000 ms (~9 hours)
console.log(SessionRetry.delay(20, error)) // 1,048,576,000 ms (~12 days)
For comparison, the same error without responseHeaders correctly returns 30,000 for all of these.
Screenshot and/or share link
Not a visual issue.
Operating System
N/A — pure TypeScript code in packages/opencode/src/session/retry.ts. Reproduces on any OS.
Terminal
Doesn't Matter.
Activity
github-actions commentedon Jun 24, 2026
This issue might be a duplicate of existing issues. Please check:
retry-afterheader is present, with the same suggested fix of applyingRETRY_MAX_DELAY_NO_HEADERSto the fallback branch.SessionRetry.policy()retrying forever with no max attempt count.github-actions commentedon Aug 24, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.