Skip to content

Configurable retry policy: expose maxRetries / initialDelay / backoffFactor / maxDelay via config #43596

Closed as not planned

Description

@yangyaofei

Configurable retry policy: expose maxRetries / initialDelay / backoffFactor / maxDelay via config

Summary

RETRY_MAX_RETRIES = 5 (introduced in #41939 / c789868) is hardcoded. For providers with long quota windows, 5 retries (~68s total) abort the turn while the error is still transient — the quota window simply hasn't reset yet. Please expose the retry constants via config so users can tune or uncap them.

Background

Before c789868 (v1.18.x, 2026-08-12), retryable errors retried indefinitely with exponential backoff. After it, packages/opencode/src/session/retry.ts caps at 5 retries:

export const RETRY_MAX_RETRIES = 5
...
if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)

This is a reasonable default. But it breaks a class of providers whose "transient" errors last much longer than 68 seconds.

Real-world case

Zhipu GLM coding plan (zhipuai-coding-plan) enforces a 5-hour usage window. When exhausted, the API returns a 429-class error:

AI_APICallError: 已达到 5 小时的使用上限。您的限额将在 2026-08-19 02:14:39 重置。
("5-hour usage limit reached. Your quota resets at 2026-08-19 02:14:39.")

Observed timeline (opencode 1.18.18, from ~/.local/share/opencode/log/opencode.log):

17:00:02  attempt 1 → 429 limit error
17:00:04  attempt 2 (+2.1s)
17:00:09  attempt 3 (+4.5s)
17:00:17  attempt 4 (+8.7s)
17:00:34  attempt 5 (+17.1s)
17:01:10  attempt 6 (+35.8s)
17:01:10  process error → turn aborted

Six attempts in 68 seconds, then the session aborts. The error itself says the quota resets hours later. Before the cap, opencode kept backing off (2s → 4s → ... → tens of minutes per attempt) and automatically resumed once the window reset — which is exactly the desired behavior for this provider class. Several coding-plan providers (Zhipu, and similar subscription-window plans) share this pattern.

Note the delay machinery already handles this well: delay() honors retry-after/retry-after-ms headers, and the exponential backoff caps at RETRY_MAX_DELAY (max int32). The binding constraint is the attempt count, not the delay.

Proposal

Expose the existing constants via config, defaults unchanged:

{
  "experimental": {
    "retry": {
      "maxRetries": 5,            // default unchanged; -1 or "unlimited" = no cap
      "initialDelayMs": 2000,
      "backoffFactor": 2,         // e.g. 1.35 for a gentler curve
      "jitterFactor": 0.25,
      "maxDelayMs": 2147483647,
      "maxDelayNoHeadersMs": 30000
    }
  }
}
  • Zero behavior change unless configured.
  • A per-provider override (provider.<id>.retry) would be a nice follow-up, but a global knob solves the immediate problem.
  • Alternatively/additionally: treat a retryable error whose stated reset time (or retry-after) exceeds the total backoff budget as "keep waiting" rather than "give up".

Why not just bump the default?

5 is a sensible default for genuinely transient errors (network blips, brief 429 spikes). The problem is heterogeneity: one user's "transient" is 30 seconds, another's is 5 hours. A config knob is the minimal fix that serves both without regressing the #41939 motivation.

Environment

  • opencode 1.18.18 (npm, linux-x64)
  • provider: zhipuai-coding-plan, model glm-5.3
  • log excerpt above

Activity

  1. github-actions commented on Aug 20, 2026

    @github-actions
    Contributor

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

    If your request is not already covered by those, please consider commenting there with your specific use case (the Zhipu zhipuai-coding-plan 5-hour window + the regression from c789868) rather than tracking it separately.

  2. JoaoBerne commented on Aug 23, 2026

    @JoaoBerne

    Hitting this from the other end: the transient network_error case on a busy free model rather than a long quota window, but the same binding constraint.

    Two things that might be worth folding into the proposal.

    Which path through delay() the error takes matters as much as the attempt count. An error with no response headers has its exponential clamped to RETRY_MAX_DELAY_NO_HEADERS, so raising maxRetries alone means every attempt from the 5th onward waits the full 30s. An error that does carry headers but no retry-after is not clamped by that constant at all (#33728), so attempt 10 waits ~17 min and attempt 15 waits ~9 h. Either way backoffFactor needs to be tunable alongside maxRetries, otherwise the knob people reach for first is the one that helps least.

    Sketch of the change, in case it helps show the surface is small:

    • packages/core/src/v1/config/config.ts: a retry struct under experimental, same shape as the neighbouring compaction and mcp_timeout entries.
    • packages/opencode/src/session/retry.ts: policy() and delay() take optional overrides; the current exported constants stay as the defaults, so behaviour is unchanged when the key is absent.
    • packages/opencode/src/session/processor.ts, at the SessionRetry.policy call: Config.Service is already in scope there, so it is threading one value through.
    • packages/opencode/test/session/retry.test.ts already drives the schedule via Schedule.toStepWithMetadata, so the override cases extend naturally.

    Happy to open the PR if a maintainer wants to assign it. CONTRIBUTING says config and product surface should get a design review first, so asking rather than sending it unprompted.

    As a stopgap I published a patcher for the compiled constants, with the measured before/after schedule: https://github.com/JoaoBerne/opencode-retry-patch. Not a substitute for the config option, and it stops being needed the day this lands.

  3. fsx8 commented on Aug 26, 2026

    @fsx8

    I really hope this can get merged at some point because the retry behaviour is totally broken

  4. yangyaofei commented on Aug 27, 2026

    @yangyaofei
    Author
  5. KayIter commented on Sep 10, 2026

    @KayIter

    We have the inverse use case for the same configuration surface: coding-agent turns can have side effects, so we need maxRetries: 0 to guarantee that the session processor does not implicitly replay a provider request.

    On OpenCode 1.18.30, setting the provider/AI SDK retry count to zero does not disable SessionRetry.policy(). With injected retryable header/chunk transport failures we observed multiple primary provider requests. A global or per-provider maxRetries option that explicitly accepts zero would cover this case while preserving the current default for other users.

    We are keeping our integration on unmodified upstream source in the meantime: the outer client reports failure and never automatically replays the Prompt, but it cannot promise a single provider attempt until this is configurable.

  6. JoaoBerne commented on Sep 23, 2026

    @JoaoBerne

    @KayIter #44517 covers maxRetries: 0: the policy stops on the first attempt, so a failed request isn't replayed. There was no test for it, there is now.

    That's the only replay I can find on the chat path. llm.ts already calls the SDK with maxRetries: 0 (only title generation passes 2) and SessionRetry.policy() is the one Effect.retry around it in the processor. I haven't gone through the outer session loop, so I'd only promise "no replay of a failed request", not more.

    Funny that it's the opposite of what I wrote it for: quota windows want unbounded, you want zero. Same knob either way.

  7. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

  8. yangyaofei commented on Oct 12, 2026

    @yangyaofei
    Author

    @thdxr nonsense, this bug/limitation still exist in V2, still hardcode max-retry

  9. JoaoBerne commented on Oct 12, 2026

    @JoaoBerne

    Re-filed for 2.0 as #54862.

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