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.
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 | 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 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.
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 500 --delay 300 --factor 1
python3 opencode-retry-patch --restore # put the pristine binary back
python3 opencode-retry-patch --binary /path/to/opencodeOutput:
/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.
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.
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- This patches a binary in place. A
.origbackup is written on first run, and--restorebrings it back. - 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.