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.
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 = 5The 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.
| 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.
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 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.
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.
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 changedOutput:
/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
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 $PROFILEThat 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.
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.
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- 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--restorebrings that exact version back. It refuses to restore a backup from a different version, since after anopencode upgradethat would silently downgrade you. - It replaces the code signature on macOS with an ad-hoc one.
opencode upgradeoverwrites 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.
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.