Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

opencode-retry-patch

opencode gives up on a turn after 5 retries, about 68 seconds. On a busy free model that is exactly when it should keep trying. This retunes the retry policy in the compiled binary, because it is not exposed through config.

python3 opencode-retry-patch

No dependencies, stdlib only. macOS and Linux are tested; Windows is implemented but I have no machine to verify it on.


The problem

packages/opencode/src/session/retry.ts hardcodes the retry policy:

export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000
export const RETRY_MAX_RETRIES = 5

The cap was added deliberately in c789868 to stop the infinite retry loops reported in #41848 and #17648. It is a sane default. It is also the wrong default when a free tier is under load and every error is transient.

There is no way to change it. The published config schema at opencode.ai/config.json has no retry key, and the plugin Hooks API has no retry hook. Issue #43596 asks for these knobs and is still open with no label and no PR. Same request in #41709, #43324 and #37412.

What changes

constant upstream patched
RETRY_MAX_RETRIES 5 20
RETRY_INITIAL_DELAY 2000 ms 1500 ms
RETRY_BACKOFF_FACTOR 2 1.15
RETRY_JITTER_FACTOR 0.25 0.2
RETRY_MAX_DELAY_NO_HEADERS 30 s 5 s

Retry schedule, at median jitter:

upstream   2250, 4500, 9000, 18000, 30000 ms          -> 5 attempts, 63.8 s of waiting
patched    1650, 1900, 2180, 2510, 2890 ... 5000 ms   -> 20 attempts, 82.7 s of waiting

In the first 30 seconds, upstream has made 4 attempts. This makes 10. Four times the samples of a flaky endpoint, for a comparable amount of waiting.

Waiting is not the whole wall-clock. Time to give up is attempts x (request time + delay), and a gateway that stalls before erroring dominates it. Against an endpoint that takes ~8 s to fail, upstream gives up after ~1.7 min and this takes ~4 min. Measure your own failure before turning the retry count up: 250 retries against that same endpoint is 35 minutes, which is why it is not the default.

Read the countdown before you shrink the delay

The TUI shows [retrying in Xs attempt #N]. Push the initial delay below a second and that notice flashes past unread, which is worse than useless when you are trying to work out whether to wait or switch models. 1.5 s is about the floor for something a person can actually read.

The backoff factor is the real lever

The obvious move is to raise maxRetries alone. It buys far less than it looks like, and it depends on which path the error takes through delay().

A network_error carries no response headers, so its exponential is clamped to RETRY_MAX_DELAY_NO_HEADERS. From attempt 5 onward every wait is the full 30 seconds. Raising the count alone buys attempts that are almost all dead time. Lowering the factor to 1.15 and the cap to 5 seconds is what turns them into actual samples.

When the error does carry headers but no retry-after, the exponential is not clamped by that constant at all, a known bug tracked in #33728. Attempt 10 waits 17 minutes, attempt 15 waits 9 hours. On that path, raising the retry count without lowering the factor is actively worse than leaving it alone.

What it does not touch

retry-after and retry-after-ms response headers are still honored, and RETRY_MAX_DELAY is left alone. A real 429 that tells you to wait 60 seconds still waits 60 seconds. Only errors with no timing headers, which is what network_error and most 5xx are, take the flat fast path.

That distinction is the whole point. Hammering a server that asked you to back off is how you get blocked. Hammering a stream that died on its own is just retrying.

Usage

python3 opencode-retry-patch                    # apply the defaults
python3 opencode-retry-patch --dry-run          # show the change, write nothing
python3 opencode-retry-patch --retries 40 --delay 2000 --factor 1.1
python3 opencode-retry-patch --restore          # put the pristine binary back
python3 opencode-retry-patch --binary /path/to/opencode
python3 opencode-retry-patch --hook             # snippet that survives upgrades
python3 opencode-retry-patch --quiet            # silent unless something changed

Output:

/Users/you/.opencode/bin/opencode
  initial delay      2000 -> 1500
  backoff factor     2 -> 1.15
  jitter             0.25 -> .2
  cap (no headers)   30000 -> 5e3
  max delay          2147483647   (unchanged)
  max retries        5 -> 20
  backup: opencode.1.18.23.orig
  patched, opencode 1.18.23 still starts

Surviving upgrades

opencode upgrade runs on its own and replaces the binary, so the patch goes with it and nothing tells you. --hook prints a shell function for your platform that reapplies it before each launch:

python3 opencode-retry-patch --hook >> ~/.zshrc      # or ~/.bashrc, or $PROFILE

That costs about 50 ms per launch. The script records the byte offset it wrote to, so the usual answer to "still patched?" is a seek and a short read rather than a scan of 144 MB, which would take about 3.7 seconds. Only a binary that actually changed pays for the full scan.

If you would rather not hook your shell, set "autoupdate": false in your opencode.json and rerun the script whenever you update by hand.

Is this your bug?

Three different failures get reported as the same thing. Only the first one is helped by retrying harder.

what you see cause does this patch help
Provider finish_reason: network_error, intermittent, mid-stream stream severed server side yes
Upstream request failed: Endpoint is unavailable, intermittent model flaky under real agent load partly, and switching model helps more
the same, on every single request provider adapter down, see #44300 no
CreditsError: Insufficient balance account balance, not a transient error no

If the failure is 100% reproducible it is not transient, and no retry policy will save you. Check with a direct call before assuming:

# no tools
curl -sS https://opencode.ai/zen/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"model":"x-preview-f-free","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'

# same call with a tool, which is what any agent actually sends
curl -sS https://opencode.ai/zen/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"model":"x-preview-f-free","max_tokens":16,"messages":[{"role":"user","content":"hi"}],
       "tools":[{"type":"function","function":{"name":"t","description":"test",
       "parameters":{"type":"object","properties":{"x":{"type":"string"}},"required":[]}}}]}'

Two measurements, since the issue tracker currently disagrees with itself about this.

On 2026-08-23, x-preview-f-free on https://opencode.ai/zen/v1 served tool payloads fine, 5 calls out of 5, so the blanket "any tools payload fails" of #44300 did not reproduce on that route. The same day ox-alpha-free on https://opencode.ai/zen/go/v1 returned CreditsError for the same key, a different problem wearing the same error in the TUI.

On 2026-08-25 the same model failed 76 times in 14 minutes inside opencode while succeeding from curl, including with a 12-tool payload and a long system prompt. So the difference was not tools, it was real streaming load, and the model is simply unreliable rather than down. laguna-s-2.1-free, mimo-v2.5-free and hy3-free answered the same payload on that route; deepseek-v4-flash-free, nemotron-3-ultra-free and muse-spark-1.2-contributor-free did not. Switching model beat retrying harder. Measure yours before assuming which bucket you are in.

How it works

The retry constants live in a Bun single-file executable. Their minified names change on every build, and opencode ships one to three releases a day, so matching them by name breaks immediately.

Instead the matcher anchors on RETRYABLE_MESSAGE_PATTERNS[0], a source-level regex literal emitted verbatim right after the constants:

…,ch=5,Jd=[/429|500|502|503|504|524/i,…
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this survives minification

The names are then read out of the match and reused, so a rename does not matter. The patched region keeps its exact byte length, padded with spaces, because a Bun executable embeds module offsets that a length change would corrupt. The script refuses to run if the match is not unique.

On macOS the ad-hoc code signature is replaced afterwards with codesign --force --sign -, otherwise the patched binary will not exec. Other platforms have nothing to re-sign. Either way the script then runs opencode --version and rolls back automatically if it does not start.

Restoring renames the backup into place instead of copying over the original. macOS caches signature validation per inode, so an in-place overwrite leaves the kernel matching a stale entry and it SIGKILLs a binary whose bytes are a perfectly valid copy. A rename gives the file a new inode and the problem goes away. This cost me an afternoon; it is why --restore also checks the result starts.

python3 test_patch.py

Warnings

  • This patches a binary in place. A backup named for the installed version, for example opencode.1.18.23.orig, is written on first run, and --restore brings that exact version back. It refuses to restore a backup from a different version, since after an opencode upgrade that would silently downgrade you.
  • It replaces the code signature on macOS with an ad-hoc one.
  • opencode upgrade overwrites the patch. Re-run the script.
  • opencode is MIT licensed. This repository ships a patcher, never a binary.
  • Unofficial. Not affiliated with the opencode project.

The real fix

This should be a config option, not a byte patch. If #43596 lands, this repository becomes useless and that is the good outcome. Go add a thumbs up to it.

MIT.

About

Make opencode keep retrying instead of giving up after ~68s. Byte-preserving patch of the compiled retry policy (RETRY_MAX_RETRIES, backoff factor) for network_error on busy free models.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages