Skip to content

Exhausted provider budgets are retried: 402 and structured quota codes are classified from message text only #47685

Open

Description

@CannonRS

Description

SessionRetry.retryable() decides whether a provider error is worth another attempt from prose:
the message and the response body are matched against RETRYABLE_MESSAGE_PATTERNS, plus two
zen-specific markers (FreeUsageLimitError, GoUsageLimitError).

A provider can report an exhausted budget without any of that — no recognisable phrase, and no long
retry-after either. Those responses are classified as ordinary retryable errors, so the session
spends all five attempts on a limit that resets on a billing or daily window, and the real cause
never surfaces.

Concrete shapes this misses today:

  • HTTP 402 — payment required. Nothing about the status is momentary.
  • OpenAI billing codes in the body: insufficient_quota, billing_hard_limit_reached,
    billing_not_active, account_deactivated. These arrive as error.type / error.code on a 429
    whose message ("You exceeded your current quota") matches the retryable patterns.
  • Google QuotaFailure with a per-day quota id, e.g.
    GenerateRequestsPerDayPerProjectPerModel, on a 429 whose message is "Resource has been
    exhausted" — which the existing exhausted branch treats as provider overload.

These are documented, stable identifiers, unlike the message text, which varies per provider and
locale.

Related but different: #39790 covers the zen fixed-window quotas and the long-retry-after case,
which #47339 and #47641 address. Neither reaches a structural budget signal that comes with a short
or absent retry-after and no zen marker.

Plugins

None

OpenCode version

dev (ea2d59d)

Steps to reproduce

  1. Use an OpenAI-compatible provider whose account has no remaining balance.
  2. Send a prompt.
  3. The provider answers 429 with insufficient_quota (or 402), the message matches the retryable
    patterns, and the session retries five times instead of reporting the exhausted budget.

Screenshot and/or share link

N/A

Operating System

Windows 11 (26200)

Activity

  1. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I tested it against a mock OpenAI-compatible provider on @opencode/cli@2.2.0 and on the latest dev (b6c43a524), counting how many requests OpenCode sends for each error:

    • HTTP 402 and 429 with insufficient_quota are already handled on both. They're classified as an exhausted quota and not retried: the mock gets one attempt per call and the provider's message is shown. The other OpenAI billing codes you list usually come with a 400/401 status, which is already not retried.
    • Google per-day QuotaFailure still reproduces. A 429 with status: "RESOURCE_EXHAUSTED", the message "Resource has been exhausted", and a QuotaFailure detail whose quotaId is GenerateRequestsPerDayPerProjectPerModel is treated as an ordinary rate limit. The mock got 12 requests (each call tried six times) before the error appeared. The classifier only reads reason from error.details[] and never looks at violations[].quotaId.

    The script below starts the mock provider and prints the request count for each case.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduce #47685: counts how many times OpenCode sends a request to a provider
    # that reports an exhausted budget. Usage: ./repro.sh [opencode command...]
    # Default command: `opencode` from PATH. For a source checkout:
    #   PRELOAD=<repo>/packages/tui/node_modules/@opentui/solid/scripts/preload.js ./repro.sh bun --conditions=browser --preload=<repo>/packages/tui/node_modules/@opentui/solid/scripts/preload.js <repo>/packages/cli/src/index.ts
    set -u
    CMD=("$@"); [ ${#CMD[@]} -eq 0 ] && CMD=(opencode)
    DIR=$(mktemp -d); cd "$DIR"; git init -q
    cat > server.ts <<'TS'
    const mode = process.env.MODE!
    let count = 0
    const bodies: Record<string, [number, unknown]> = {
      openai_insufficient_quota: [429, { error: { message: "You exceeded your current quota, please check your plan and billing details.", type: "insufficient_quota", code: "insufficient_quota" } }],
      http_402: [402, { error: { message: "Payment required" } }],
      google_per_day_quota: [429, { error: { code: 429, message: "Resource has been exhausted (e.g. check quota).", status: "RESOURCE_EXHAUSTED",
        details: [{ "@type": "type.googleapis.com/google.rpc.QuotaFailure", violations: [{ quotaMetric: "generativelanguage.googleapis.com/generate_requests_per_model_per_day", quotaId: "GenerateRequestsPerDayPerProjectPerModel" }] }] } }],
    }
    Bun.serve({ port: Number(process.env.PORT), fetch(req) {
      const u = new URL(req.url)
      if (u.pathname.endsWith("/chat/completions")) { count++; console.error(`REQUEST ${count} ${mode}`); const [s, b] = bodies[mode]; return Response.json(b, { status: s }) }
      if (u.pathname.endsWith("/models")) return Response.json({ data: [{ id: "m", object: "model" }] })
      return new Response("nf", { status: 404 })
    } })
    TS
    PORT=18765
    # When running from source, spawned server processes need the OpenTUI preload too.
    [ -n "${PRELOAD:-}" ] && printf "preload = [\"%s\"]\n" "$PRELOAD" > bunfig.toml
    cat > opencode.json <<JSON
    { "\$schema": "https://opencode.ai/config.json",
      "provider": { "mock": { "npm": "@ai-sdk/openai-compatible", "name": "Mock", "options": { "baseURL": "http://127.0.0.1:$PORT/v1", "apiKey": "x" }, "models": { "m": { "name": "m" } } } },
      "model": "mock/m" }
    JSON
    for MODE in openai_insufficient_quota http_402 google_per_day_quota; do
      MODE=$MODE PORT=$PORT bun server.ts 2> server.log & SP=$!; sleep 1
      timeout 90 "${CMD[@]}" run --standalone --model mock/m "hi" > out.log 2>&1; RC=$?
      kill $SP; wait $SP 2>/dev/null
      echo "== $MODE: exit=$RC requests=$(grep -c REQUEST server.log) (timeout=124)"; tail -3 out.log | cut -c1-300
    done

    Run it: bash repro.sh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions