Repository navigation
Description
fix(agent): a 403 whose error body self-identifies as server_error should be retried, not treated as a credential refusal
Summary
OpenCode Go (an OpenAI-compatible relay at https://opencode.ai/zen/go/v1) wraps transient upstream failures as:
HTTP 403
{"error": {"type": "server_error", "code": "server_error",
"message": "Upstream request failed: [server_error] Upstream response was not valid JSON"}}
classify_api_error maps this to FailoverReason.auth / retryable=False, so the turn aborts on attempt 1 and never uses its retry budget. Observed twice on Sep 21 (UTC), both times killing a scheduled cron job mid-run:
00:40:28 ERROR Streaming failed before delivery: Error code: 403 - {'error': {'type': 'server_error', ...}}
00:40:29 WARNING API call failed (attempt 1/3) error_type=PermissionDeniedError
00:40:29 ERROR Non-retryable client error: ...
00:40:29 WARNING Job '…': session ended without a final assistant message (lifecycle=interrupted)
The same relay serves the same request successfully seconds later — it's a blip, not a credential problem. (The Go docs themselves label this error class "Upstream response was not valid JSON".)
Root cause
agent/error_classifier.py::_status_403 has a transient-code escape hatch:
_403_TRANSIENT_CODES = frozenset({"upstream_unavailable"})added by #115796 (merged 2026-09-19, resolving case 1 of #75388). OpenCode's structured code for the same situation is server_error — a different literal, so it falls through to the generic auth default.
Proposed fix (one line)
_403_TRANSIENT_CODES = frozenset({"upstream_unavailable", "server_error"})Reproduction & verification (live, on the installed v0.21.3 tree)
classify_api_error(err, provider="opencode-go", model="glm-5.3-flash", base_url="https://opencode.ai/zen/go/v1"):
| 403 body | before | after (proposed) |
|---|---|---|
{"type": "server_error", "code": "server_error", "message": "Upstream request failed: …"} |
auth, retryable=False |
overloaded, retryable=True |
{"message": "Forbidden", "type": "permission_error"} (real credential refusal — control) |
auth, retryable=False |
auth, retryable=False (unchanged) |
{"code": "insufficient_quota"} (billing — control) |
billing, retryable=False |
billing, retryable=False (unchanged) |
{"code": "upstream_unavailable"} (existing transient — control) |
overloaded, retryable=True |
overloaded, retryable=True (unchanged) |
So the change widens the retry to exactly the class #75388/#115796 already established (structured server-side error on a 403 = transient), while real credential refusals (permission_error, access denied, WAF blocks) and billing 403s keep their existing verdicts.
Why this matters beyond one relay
The failure mode is not OpenCode-specific: any OpenAI-compatible aggregator that fronts flaky upstreams can legitimately answer 403 with a server-side error body. Today the agent (a) burns the whole cron/agent turn, (b) benches/rotates the credential via the credential pool (marking … exhausted (status=403), rotating), and (c) skips fallback_providers, because non-retryable client errors settle before the fallback walk. All three are wrong for a transient upstream hiccup.
Environment
- Hermes Agent v0.21.3 (2026.9.14) @ 845fee6 (updated today, includes fix(agent): a 403 stamped upstream_unavailable is retried with backoff instead of failing as an auth error (#75388) #115796)
- provider:
opencode-go,chat_completionsmode - Reproduced with a minimal
classify_api_errorcall against the real error body captured from production logs (happy to attach full log excerpts)
Activity
kokhlo commentedon Sep 21, 2026
Confirmed on main:
agent/error_classifier.py:1000keeps_403_TRANSIENT_CODES = frozenset({"upstream_unavailable"}), and_status_403(line 1003) returns_V_OVERLOADEDonly for that literal. OpenCode'sserver_errorcode falls through the billing scan and the WAF check into_V_AUTH_FALLBACK— which is what aborts the turn on attempt 1 and benches a healthy credential.The fix widens exactly the class #115796 established for #75388: a structured server-side error code on a 403 means "upstream is down, retry later", so
server_errorjoins the transient set. Real credential refusals (permission_error,access denied, WAF blocks) and billing 403s (insufficient_quota,key limit exceeded) never carry that code, so all the control cases in the report keep their verdicts.Plan: one-line set extension + a regression case next to the existing
upstream_unavailabletests (403 body{"type": "server_error", "code": "server_error"}→ overloaded/retryable;permission_errorandinsufficient_quotacontrols unchanged). PR within the hour.Sahilvishnaliya commentedon Sep 21, 2026
Claiming this — working on a PR. @Sahilvishnaliya
Sahilvishnaliya commentedon Sep 21, 2026
Claiming this — working on a PR. @Sahilvishnaliya
foxhoundhcm commentedon Sep 21, 2026
Independent confirmation from a Windows desktop install — provider
opencode-go(https://opencode.ai/zen/go/v1, modeldeepseek-v4.1-flash), same body every time:8 occurrences in one day (2026-09-21, UTC+8) in 6 clusters — 09:32, 09:38, 10:07, 19:46, 20:51, 20:56 — so this is a steady background rate, not a one-off. Two user-visible consequences that the fix should account for:
_V_AUTH_FALLBACKroutes the turn to the canned auth copy (agent/turn_failure_copy.py:183,_AUTH_COPY["api_key"]), so the desktop reports "OpenCode Go rejected your API key — The key saved for OpenCode Go is invalid or was revoked. Update it, then retry." and offers an Update API key button for what is a relay blip. Users are being told to rotate a healthy key.credential_pool._mark_exhaustedpersistslast_status=exhausted,last_error_code=403,last_error_reason=server_error,failure_reason=authintoauth.json, and_exhausted_ttl_secondsgives a non-billing 403 on a sole credentialEXHAUSTED_TTL_SOLE_CREDENTIAL_SECONDS(60s) — so the retry a minute later succeeds and the operator sees a random failure that "goes away by itself" (as reported here), while every intervening turn on that attempt dies outright.A/B on the installed tree, real body,
classify_api_error(..., provider="opencode-go", base_url="https://opencode.ai/zen/go/v1"):_403_TRANSIENT_CODES{"upstream_unavailable"}(current)auth+ "server_error"overloadedSame direction as the report, and the retry budget is then spent as intended. One note for whoever lands the fix: whether the code is honoured globally or only for this relay is your call, but the ordering matters — in the current
_status_403the transient-code check runs before the billing and WAF checks, so a global set extension would also shadow a 403 that carries billing prose alongsidecode=server_error. Both open PRs (#117871 provider-scoped, #117874 wrapper-marker-gated) sidestep that by reading the transient code last; either shape fixes our case, so we're only asking for the ordering to stay deliberate.We're running the one-line set extension as a local patch until this lands, and will test a merged build against the same relay — happy to report back.
weidzhou commentedon Sep 21, 2026
Independent user-side reproduction on v0.21.3 (2026.9.14), plus one observation the merged fix does not cover.
The gap: #115796 (merged 2026-09-19) retries a 403 stamped
code: upstream_unavailable. What this relay actually sends iscode: server_error:So the merged wording doesn't fire here, and the healthy credential gets benched.
Evidence chain — 2026-09-21 22:52:39,
platform=webui,provider=opencode-go,model=deepseek-v4.1-flashERROR agent.conversation_loop: Non-retryable client error: Error code: 403 - {...}reset_at, so_exhausted_ttl(403)→EXHAUSTED_TTL_DEFAULT_SECONDS= 3600 s of being skipped — andauth listprints “re-auth may be required” while hiding the window (hermes_cli/auth_commands.py::_EXHAUSTED_CLASSESmaps{401,403}→ labelauth failed,show_retry_window=False).429 GoUsageLimitError … metadata={'limitName': 'monthly'}→Retrying API call in 600s⇒ the UI shows “processing” with no explanation for up to an hour.
Ask — beyond retryability (#117874 / #117871 / #117911), it would help to also:
code: server_erroras transient incredential_poolso the credential is not benched for 3600 s, andauth failed … (re-auth may be required)(it sends operators hunting a key problem that doesn't exist).Filed as #118286 (closed as duplicate of this issue).
Fission21 commentedon Sep 22, 2026
Third independent reproduction — desktop install, plus a direct proof the credential was never the problem
Same relay, same body,
provider=opencode-go/model=deepseek-v4.1-flash, on v0.21.3 (2026.9.14), commitfbc4ea8b96(macOS, desktop app).Our timeline (UTC+8): 2026-09-21 14:58 / 15:04 / 17:11 — three clusters, 15 log hits, zero occurrences before or after in the retained logs. In UTC that is 06:58 / 07:04 / 09:11, filling the gap between the clusters already reported in this thread.
The chain is exactly as described:
What I'd add: a direct check of the credential. Using the same key, minutes after the last failure and again the next morning:
So the "bench the credential" half of this bug is demonstrably wrong: a single-key pool goes to no available entries after a blip, and the user-facing advice ("Update API key") recommends the one action that cannot help.
User-visible impact beyond the aborted turn:
failure_reason=authdrives the desktop banner "The key saved for OpenCode Go is invalid or was revoked. Update it, then retry." — copy inapps/desktop/src/i18n/en.ts:4217, agent sideagent/turn_failure_copy.py:185— with an Update API key action button. Retrying the turn works; rotating the key does not.Happy to re-test once a PR lands. Our tree already carries the
_403_TRANSIENT_CODESescape hatch from #115796 (agent/error_classifier.py:1000), so I can flip the set locally, re-run the classifier cases, and run a live turn against the relay.Hamakaze1s commentedon Oct 18, 2026
Independent field evidence for the same relay envelope, plus one body in that family this change would swallow.
Environment: hermes-agent
local-patcheson a3068d1c177-era main (Linux,opencode-goviahttps://opencode.ai/zen/go/v1, modeldeepseek-v4.1-flash). No keys or account details below.The relay reuses this envelope for a second, unrelated condition. Same HTTP 403, same
error.type/error.code == "server_error", sameUpstream request failed:wrapper — only the message text differs:(1) is the blip this issue is about. (2) is an account that has no Go subscription — that credential cannot serve Go traffic at all, so it must be benched and rotated away.
What (2) does today, measured through the production entry points (
classify_api_errorfor the verdict;recover_with_credential_poolwith a real two-entryCredentialPoolfor the pool reaction, the subscription-less key at priority 0):auth, retryable=Falseauth, retryable=FalseThis API key has expired…(control)auth, should_rotate=TrueSo today both bodies bench a credential for an hour — the behaviour you report for (1) — and (2) happens to land on the correct recovery (rotate away from a key that cannot serve Go traffic) purely because core's 403 default benches.
Why the fix shape matters for (2). A check keyed on
code == "server_error"alone — or oncodeplus theUpstream request failedwrapper — claims (2) as well and makes itoverloaded:recover_with_credential_poolhas no branch for it (agent/agent_runtime_helpers.py:1098-1133), so the key is never benched and stays selected, spending the retry budget on every call. That is strictly worse than today. Note_code_from_payload(agent/error_classifier.py:1461) falls back toerror.type, so body (2) — which carries nocode— still arrives asserver_errorat any code-only predicate.Suggested shape for whoever lands this: keep the transient classification for (1), but claim (2) in a provider-scoped branch ordered before the code branch. The
ProviderProfile.classify_api_errorseam already runs ahead of_status_403(_profile_verdictis the second stage of_STAGES), e.g.billing(rather thanmodel_entitlement, which benches the credential+model pair for a year and clears only throughhermes auth reset, or a DEAD row, which drops a manual entry after 24h) keeps the one-hour bench on single-key pools too and re-arms the per-turn revert hook, so a renewed subscription recovers without manual intervention.Zero-code workaround for anyone hitting (2) today:
hermes auth priority opencode-go <subscribed-key> 0puts the working key first.Repro is pure-local (no network): feed the two bodies to
classify_api_error(err, provider="opencode-go", model=<id>, base_url=<relay>), and for the pool half driverecover_with_credential_poolwith a two-entryCredentialPool. Happy to share the probe script if useful.alt-glitch commentedon Oct 18, 2026
#117871 aims to fix this: it adds the server error code to the list of transient 403 codes, so a relay 403 marked as a server error gets retried instead of being treated as a credential refusal.
fix(opencode): preserve Go upstream 429 when retrying relay 403