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 (branch dev)
- File:
packages/opencode/src/session/retry.ts, function delay, 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.
Description
Retry backoff is uncapped when a retryable error has response headers but no
retry-afterSummary
SessionRetry.delay()inpackages/opencode/src/session/retry.tsonly applies the 30-secondRETRY_MAX_DELAY_NO_HEADERScap in the branch that handles errors with no response headers. When a retryable error carries anyresponseHeadersobject (the common case — every real 502/503 from every provider sendscontent-type,date,server, etc.) but the headers do not include a parseableretry-after/retry-after-ms, the fallback path uses uncapped exponential backoff bounded only byRETRY_MAX_DELAY(2,147,483,647 ms ≈ 24 days, thesetTimeoutlimit).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
For comparison, the same error without
responseHeaderscorrectly returns30_000for all of these.Expected behavior
RETRY_MAX_DELAY_NO_HEADERS(30 s) should cap exponential backoff whenever no usableretry-afterhint is present — regardless of whether other response headers exist. Response headers likecontent-typecarry no retry signal and should not disable the cap.Actual behavior
The cap is bypassed whenever
error.data.responseHeadersis truthy. Since real HTTP responses almost always include headers, the cap is effectively a dead letter in production.Environment
4b83f5d(branchdev)packages/opencode/src/session/retry.ts, functiondelay, lines 35–66Suggested 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.Why the existing test missed it
test/session/retry.test.ts:36is named "caps delay at 30 seconds when headers missing" and exercisesapiError()(no headers). The two related tests that do pass headers (ignores invalid retry hints,ignores malformed date retry hints) only calldelay(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.