Skip to content

Latest commit

 

History

1 Commit

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, Linux and WSL.


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 250
RETRY_INITIAL_DELAY 2000 ms 500 ms
RETRY_BACKOFF_FACTOR 2 1
RETRY_JITTER_FACTOR 0.25 0.2
RETRY_MAX_DELAY_NO_HEADERS 30 s 2 s

Retry schedule, at median jitter:

upstream   2250, 4500, 9000, 18000, 30000 ms   -> gives up after 63.8 s
patched     550,  550,  550,   550,   550 ...  -> 250 attempts over 137 s

In the first 30 seconds, upstream has made 4 attempts. The patch has made about 55.

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 to 250 would spend over two hours mostly sitting idle. Dropping the factor to 1 and the cap to 2 seconds is what turns those retries into actual attempts.

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 500 --delay 300 --factor 1
python3 opencode-retry-patch --restore          # put the pristine binary back
python3 opencode-retry-patch --binary /path/to/opencode

Output:

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

Re-run it after every opencode upgrade. The updater replaces the binary and your patch goes with it. Or set "autoupdate": false in your opencode.json.

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
503 Upstream request failed: Endpoint is unavailable, on every request provider adapter down for payloads containing tools no, see #44300
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":[]}}}]}'

One measurement, 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 out of 5 calls succeeded, 3 non-streaming and 2 streaming at 557 and 626 chunks. 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, which is a different problem wearing the same error message in the TUI. Routes differ, so measure yours.

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.

python3 test_patch.py

Warnings

  • This patches a binary in place. A .orig backup is written on first run, and --restore brings it back.
  • 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