Repository navigation
fix(session): SessionRetry.policy() retries forever with no max attempt count #21960
Description
Bug
SessionRetry.policy() creates an Effect.Schedule that retries retryable errors (429, 529, overloaded) indefinitely with no maximum attempt count and no maximum total retry duration.
File: packages/opencode/src/session/retry.ts
Root Cause
The policy() function has exactly two branches:
- Stop:
Cause.done(meta.attempt)— only whenretryable()returnsundefined(non-retryable error) - Continue:
[meta.attempt, Duration.millis(wait)]— whenever the error IS retryable
There is no check for meta.attempt >= MAX_RETRIES. Once an error is classified as retryable, the schedule will retry forever.
// retry.ts — policy()
const message = retryable(error)
if (!message) return Cause.done(meta.attempt) // STOP (only for non-retryable)
return Effect.gen(function* () {
const wait = delay(meta.attempt, ...)
// ... set status ...
return [meta.attempt, Duration.millis(wait)] // CONTINUE (forever)
})Impact
When a provider returns persistent 429/overloaded errors:
- OpenCode retries every 30s (backoff caps at
RETRY_MAX_DELAY_NO_HEADERS = 30_000ms) - Session status stays
"retry"—session.errorandsession.idleare never emitted - Plugin consumers (oh-my-openagent) that rely on these events for task lifecycle management are stuck waiting indefinitely
- Tasks appear "running" for 30-45 minutes until a stale timeout fires (if one exists)
Observed behavior: 1,691 failed API calls to openai/gpt-5.3-codex-spark (unavailable model) in a single day, all returning errors, all retried forever with zero tokens generated.
Backoff Sequence
Without response headers: 2s → 4s → 8s → 16s → 30s → 30s → 30s → ... (caps at 30s per attempt, never stops)
With retry-after-ms header: individual delays up to RETRY_MAX_DELAY = 2,147,483,647ms (~24.8 days) per attempt.
Suggested Fix
Add a configurable max retry count to policy():
export const DEFAULT_MAX_RETRIES = 20
// At the top of the schedule step function:
if (meta.attempt >= (opts.maxRetries ?? DEFAULT_MAX_RETRIES)) {
return Cause.done(meta.attempt)
}This would allow the schedule to eventually terminate, halt() to run, and session.error to be emitted — giving consumers a clean error signal instead of an infinite wait.
Related Issues
- Some transient stream/rate-limit errors bypass retry and terminate sessions #21893 — Some transient stream/rate-limit errors bypass retry and terminate sessions (opposite problem: errors that SHOULD retry but don't)
- Add retry for 429 #21465 — Add retry for 429 (429 errors wrapped in TypeValidationErrors bypass retry)
- Bug: Claude/Anthropic 500/502/503/504 server errors are not retried #21422 — Claude/Anthropic 500/502/503/504 server errors are not retried
Environment
- OpenCode version: latest
- Plugin: oh-my-openagent (background task manager)
- Observed with:
openai/gpt-5.3-codex-spark(unavailable),anthropic/claude-opus-4-6(rate-limited)
Activity
github-actions commentedon Apr 11, 2026
This issue might be a duplicate of existing issues. Please check:
model_not_foundcauses infinite retry loop #14723:model_not_founderrors causing an infinite retry loopIf your issue is distinct (e.g., specific to the
opts.maxRetriesAPI surface or the suggested fix approach), please clarify how it differs from the above.truenorth-lj commentedon May 8, 2026
Hi @dangeReis — thanks for the detailed writeup; this matches exactly what I'd been hitting. PR #26369 implements your proposal: adds
RETRY_MAX_ATTEMPTS = 3alongside the existingRETRY_*constants and a cap check at the top of thepolicy()schedule step. With the existing 2-4-8 s backoff that gives a wall-clock retry budget of ~14 s.It's already merged on top of @rekram1-node's #26366
retryable(error, provider)refactor, so the cap sits cleanly in front of the new provider-aware classification. Compliance bots green; CI workflows are ataction_requiredwaiting for maintainer approval since it's a fork branch.Hope this helps — and credit to you for the original spec.
carlosflorencio commentedon May 31, 2026
Still reproduces on
opencode acp(v1.15.12, currentdev) and it's especially bad over ACP. Driving opencode from an external ACP client, a hardopencode-goweekly usage limit makes the turn hang with zero frames to the client until the client gives up.Wire capture (external ACP client, model
opencode-go/kimi-k2.6):Meanwhile opencode's own logfile shows the provider error that's being retried:
Why it's unbounded: in
session/retry.ts,retryable()returns a non-null object forGoUsageLimitError(theresponseBody.includes("GoUsageLimitError")branch), anddelay()readsretry-after(~21,360s here → ~5.9h, under theRETRY_MAX_DELAY≈ 24.8-day cap).policy()builds aSchedulethat only terminates whenretryable()is falsy — so it sleeps ~6h, retries, gets the same 429, sleeps ~6h again, forever. Becausesession/promptis synchronous, the ACP client sees nothing the whole time (the TUI at least renders the retry status; ACP clients have no equivalent).Capping attempts (#26369) helps the general case, but for the hard Go weekly/monthly limit the
retry-afteris hours, so even a few attempts is hours of client-invisible silence. This class (action.reason === "account_rate_limit") is arguably never worth retrying within a session and should fail fast instead — see also the closed #24013, which tried exactly that. The ACP-visibility half is tracked in #27779 / #26378.fix(session): cap retry attempts and individual retry waits
EvolveAegis commentedon Aug 1, 2026
Confirming with a direct test of the exported
policy()andretryable(): drove 1000 consecutive steps throughSchedule.toStepWithMetadata(same harness asretry.test.ts). Result: 1000/1000 continue-decisions,Cause.donecount = 0. The only errorretryable()refuses isContextOverflowError— all 5xx + "Overloaded" return truthy indefinitely.Tested against
packages/opencode/src/session/retry.tson commit69a8066.djbclark commentedon Aug 2, 2026
Confirming this reproduces on
opencode run(not justacp) — v1.18.10 and v1.18.11, macOS.Trigger:
opencode-go's monthly quota exhausted (confirmed via account status, 0% remaining for the month), which the provider surfaces as a 429.opencode run --auto --model opencode-go/<any-model> "<prompt>"then hangs indefinitely with zero output — no error, no timeout, just silence. Reproduced across multipleopencode-gomodels (qwen3.7-max, deepseek-v4-flash, kimi-k2.7-code), so it's not model-specific.Ruled out as unrelated before landing on this root cause: a local telemetry plugin (has a bounded 2s socket timeout, can't be the cause),
--pure(disables plugins entirely, hang persists),CI=true, and stdin explicitly closed (< /dev/null) — none of these change the outcome.This is a bad failure mode specifically because it's silent: a caller has no way to distinguish "still thinking" from "wedged forever retrying a 429 that will never succeed until next month's quota reset." Even a capped retry count with a clear terminal error would be a big improvement over the current indefinite hang.
feat: monitor anomalyco/opencode#21960 (retry-cap bug) via GitHub Act…
eyalatox commentedon Aug 11, 2026
Opened #41699 — caps retries at 10 by default, configurable via provider options.
neriousy commentedon Oct 1, 2026
In OpenCode v2, session retries are capped. A failed provider request is retried at most 10 times, with exponential backoff capped at 10 seconds between attempts (about 84 seconds in total). A provider's
retry-afterheader is honored for up to 15 minutes.Fixed in #49441.
Closing as completed. If retries still run forever on v2, please open a new issue.