Skip to content

fix(session): SessionRetry.policy() retries forever with no max attempt count #21960

Closed

Description

@dangeReis

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:

  1. Stop: Cause.done(meta.attempt) — only when retryable() returns undefined (non-retryable error)
  2. 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:

  1. OpenCode retries every 30s (backoff caps at RETRY_MAX_DELAY_NO_HEADERS = 30_000ms)
  2. Session status stays "retry" — session.error and session.idle are never emitted
  3. Plugin consumers (oh-my-openagent) that rely on these events for task lifecycle management are stuck waiting indefinitely
  4. 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

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

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 11, 2026
  2. github-actions commented on Apr 11, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is distinct (e.g., specific to the opts.maxRetries API surface or the suggested fix approach), please clarify how it differs from the above.

  3. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  4. truenorth-lj commented on May 8, 2026

    @truenorth-lj

    Hi @dangeReis — thanks for the detailed writeup; this matches exactly what I'd been hitting. PR #26369 implements your proposal: adds RETRY_MAX_ATTEMPTS = 3 alongside the existing RETRY_* constants and a cap check at the top of the policy() 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 at action_required waiting for maintainer approval since it's a fork branch.

    Hope this helps — and credit to you for the original spec.

  5. carlosflorencio commented on May 31, 2026

    @carlosflorencio

    Still reproduces on opencode acp (v1.15.12, current dev) and it's especially bad over ACP. Driving opencode from an external ACP client, a hard opencode-go weekly 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):

    → initialize            ✅
    → session/new           ✅
    → session/set_model     ✅  (opencode-go/kimi-k2.6)
    → session/prompt        … no response, ever
    (no session/update, no stopReason, no error — client killed after 120s)
    

    Meanwhile opencode's own logfile shows the provider error that's being retried:

    statusCode: 429
    type:       GoUsageLimitError
    url:        https://opencode.ai/zen/go/v1/chat/completions
    message:    "Weekly usage limit reached. Resets in 5hr 56min. To continue
                 using this model now, enable usage from your available balance:
                 https://opencode.ai/workspace/wrk_…/go"
    

    Why it's unbounded: in session/retry.ts, retryable() returns a non-null object for GoUsageLimitError (the responseBody.includes("GoUsageLimitError") branch), and delay() reads retry-after (~21,360s here → ~5.9h, under the RETRY_MAX_DELAY ≈ 24.8-day cap). policy() builds a Schedule that only terminates when retryable() is falsy — so it sleeps ~6h, retries, gets the same 429, sleeps ~6h again, forever. Because session/prompt is 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-after is 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.

  6. added a commit that references this issue on Jul 11, 2026
    123bb39
  7. EvolveAegis commented on Aug 1, 2026

    @EvolveAegis

    Confirming with a direct test of the exported policy() and retryable(): drove 1000 consecutive steps through Schedule.toStepWithMetadata (same harness as retry.test.ts). Result: 1000/1000 continue-decisions, Cause.done count = 0. The only error retryable() refuses is ContextOverflowError — all 5xx + "Overloaded" return truthy indefinitely.

    Tested against packages/opencode/src/session/retry.ts on commit 69a8066.

  8. djbclark commented on Aug 2, 2026

    @djbclark

    Confirming this reproduces on opencode run (not just acp) — 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 multiple opencode-go models (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.

  9. added a commit that references this issue on Aug 2, 2026
  10. eyalatox commented on Aug 11, 2026

    @eyalatox

    Opened #41699 — caps retries at 10 by default, configurable via provider options.

  11. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    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-after header 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions