fix(opencode): retry OpenRouter upstream errors reported inside a 200 stream

Merged
GO
2mo ago
+39-0

A sandbox turn that hits an upstream rate limit or a dropped stream on an OpenRouter-routed model now retries instead of dying on the first hit. Every customer build on those models today ends at the first provider hiccup; this makes them survive the ordinary ones.

Context

OpenRouter forwards upstream failures as a JSON object inside the HTTP 200 SSE body, e.g. {"code":502,"message":"Upstream error from Together: Stream error: h2 protocol error…","metadata":{"error_type":"provider_unavailable"}}. The OpenRouter SDK emits that as a stream error rather than an APICallError, so opencode wraps it as UnknownError with the JSON as its message and no statusCode. retryable() then never reaches the status branch that retries any 5xx, and its JSON branch only reads code when it is a string, so OpenRouter's numeric code matches nothing. Result: no retry, turn fails.

Observed across the cheap-model bakeoff in andytown: every DeepSeek V4.1 Flash unit died on a 429 last week and seven Together stream drops killed three of four units today (bakeoff-r5-grok-ds41), none retried. Same-model retry is the only recovery path in production; the model-fallback config the coordinator ships is not executed by anything (REPL-31522). Upstream has the identical gap and closed it as not planned: anomalyco/opencode#22448.

Change

In the JSON branch of retryable(), apply the rule the APIError branch already uses: a numeric code of 429 retries as rate-limited, a numeric code of 500 or above retries as a transient provider failure. Nothing else changes: same backoff, same attempt cap, and 4xx request errors (for example a rejected tool schema) still do not retry.

Testing Done

Three new cases in test/session/retry.test.ts using the real payloads from the failed sessions: the 429 rate_limit_exceeded, the Together 502 provider_unavailable, and a 400 schema rejection that must not retry. bun test test/session/retry.test.ts: 36 pass, 0 fail.

Follow-up once tagged: bump OPENCODE_VERSION in andytown's snapshot Dockerfile and rerun the DeepSeek rows.

🤖 Generated with Claude Code


Devin Review

Summary by cubic

Retries OpenRouter upstream errors that arrive as JSON inside a 200 SSE stream, so sandbox turns survive rate limits and provider 5xxs instead of dying on the first hiccup. Previously these errors were treated as unknown with no numeric status, so they never triggered a retry. 4xx request errors (like a rejected tool schema) still don't retry.

  • Applies the same rule as the APIError branch: numeric 429 retries as rate-limited, numeric 5xx retries as a transient provider failure.
  • Excludes context-overflow errors relayed with a 502 code so they reach compaction instead of the retry loop.
  • Adds tests covering the 429, provider 5xx, overflow exclusion, and 4xx non-retry cases.