Skip to content

auth: Console device/code returns 429 with no Retry-After, so the CLI cannot say how long to wait #56499

Open

Description

@lucasvidela94

Summary

POST /console/auth/device/code returns 429 with a structured RateLimited body and no Retry-After header. The client's own backoff logic in packages/opencode/src/session/retry.ts derives its delay from retry-after-ms / retry-after and falls back to a guessed exponential schedule when they are absent. The Console is the party that knows when the window resets and does not communicate it, so the client cannot recover deterministically.

Detected while working through #56498 / #52227; the triage bot suggested filing this separately, which is why it is its own issue.

Environment

  • opencode version: v2.0.29 (channel: latest)
  • OS: Linux 7.2.5-3-omarchy (linux x64)
  • Shell: /usr/bin/bash
  • Install/channel: release (latest)

Reproduction

curl -i -X POST https://opencode.ai/console/auth/device/code \
  -H 'content-type: application/json' \
  -d '{"client_id":"opencode-cli"}'

Expected Behavior

Either a 200 with a device code, or a 429 carrying Retry-After (or retry-after-ms) so the client can wait the correct interval instead of guessing.

Actual Behavior

HTTP/1.1 429
{"_tag":"RateLimited","message":"Too many requests, please try again later"}

Three properties worth noting:

No Retry-After. The header is entirely absent from the response. retry.delay() in packages/opencode/src/session/retry.ts checks retry-after-ms, then retry-after (numeric seconds and HTTP-date forms), and only then falls back to RETRY_INITIAL_DELAY * RETRY_BACKOFF_FACTOR ** attempt, capped at 30s. Without the header the client is guessing, and the guess is very likely shorter than the actual window — which turns one rate limit into a retry loop that keeps re-triggering it.

Deterministic for a single client, not a burst artifact. Reproduced 3/3 consecutive attempts from one client, and again from a separate independent curl client, across a ~30-minute window spanning several CLI invocations. There was no request burst preceding it.

Content-type and client_id are ruled out as causes. The v2 Console API docs document the device flow with form-encoded bodies and an arbitrary client_id (client_id=my-cli, plus supports_org_scope=true). Sending form-encoded and sending JSON — the shape the client actually uses via HttpClientRequest.schemaBodyJson(ClientId) in packages/opencode/src/account/index.ts — return byte-identical 429s. The client_id used here is the one hardcoded in that file ("opencode-cli"); a bad client id returns 400, not 429.

# both → HTTP 429 {"_tag":"RateLimited","message":"Too many requests, please try again later"}
curl -X POST https://opencode.ai/console/auth/device/code --data "client_id=opencode-cli"
curl -X POST https://opencode.ai/console/auth/device/code -H 'content-type: application/json' -d '{"client_id":"opencode-cli"}'

Additional Context

The rate limiting itself is fine and clearly deliberate — {"_tag":"RateLimited"} is a structured Effect-TS error from the Console server, not an intermediary WAF or CDN block. The ask is only the Retry-After header, so the client's existing backoff path works as designed instead of degrading to a guess.

Happy to provide a curl -i full header dump or a --print-logs trace if it helps.

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced it.

    • From a fresh client, POST https://opencode.ai/console/auth/device/code returned 200.
    • After a quick burst of about 30 requests it switched to 429 with {"_tag":"RateLimited","message":"Too many requests, please try again later"}.
    • The 429 had no Retry-After or retry-after-ms header, even though the response lists retry-after in access-control-expose-headers. The limit lasted well over a minute.
    • While rate limited, opencode auth login opencode --method device failed straight away with Authentication failed / StatusCode: non 2xx status code (429 POST https://opencode.ai/console/auth/device/code). That happened on 2.0.29 and on the latest dev (86c7f7c), with no hint of how long to wait.

    The script below reproduces it; it sends a burst on purpose to trip the limit.

    Two notes:

    • I couldn't confirm that a single client gets 429 every time with no burst before it. My fresh requests succeeded until I sent the burst.
    • The paths you mention (packages/opencode/src/session/retry.ts, account/index.ts) are v1 code. In v2 the device-code request is made from packages/core/src/plugin/provider/opencode.ts, and it doesn't retry: a 429 just ends the login. So a Retry-After header from the Console would mainly let the CLI tell you how long to wait.

    Related: #56498 and #52227.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#56499:
    # POST /console/auth/device/code returns 429 RateLimited without a Retry-After header,
    # and `opencode auth login` then fails immediately with no hint of when to retry.
    # NOTE: this deliberately sends a short burst to trip the Console rate limit.
    set -u
    URL=https://opencode.ai/console/auth/device/code
    BODY='{"client_id":"opencode-cli","supports_org_scope":true}'
    OUT=$(mktemp -d)
    
    echo "== burst of 40 device/code requests =="
    for i in $(seq 1 40); do
      curl -sS -m 20 -o "$OUT/b$i" -D "$OUT/h$i" -w "%{http_code}\n" -X POST "$URL" \
        -H 'content-type: application/json' -d "$BODY"
    done | sort | uniq -c
    
    f=$(grep -l '^HTTP/[0-9.]* 429' "$OUT"/h* | head -1)
    if [ -z "$f" ]; then echo "no 429 seen"; exit 1; fi
    echo "== first 429 response =="
    cat "$f"; cat "$OUT/b${f##*/h}"; echo
    if grep -qi '^retry-after' "$f"; then echo "Retry-After PRESENT"; else echo "Retry-After / retry-after-ms ABSENT"; fi
    
    echo "== probing every 15s to see how long the limit lasts =="
    for n in 1 2 3 4 5; do
      sleep 15
      echo "$(date -u +%T) $(curl -sS -m 20 -o /dev/null -w '%{http_code}' -X POST "$URL" -H 'content-type: application/json' -d "$BODY")"
    done
    
    echo "== opencode auth login while rate limited =="
    opencode --version
    export HOME=$(mktemp -d)
    cd "$(mktemp -d)"
    timeout 60 opencode auth login opencode --method device --standalone </dev/null 2>&1 | tail -8

    Run it: bash repro.sh

  2. lucasvidela94 commented on Oct 17, 2026

    @lucasvidela94
    Author

    Both corrections are right, and I want to flag the first one because it changes the diagnosis.

    On the burst: my "no burst" claim was wrong. I wrote that the 429 was "persistent, not a burst artifact" based on 3/3 consecutive failures. What I did not account for is that those attempts came at the end of a long debugging session during which I had already hit /console/auth/device/code repeatedly — testing form-encoded against JSON, checking Retry-After, and confirming the 401 on the old key. So my "single client" was not a cold client, and your finding (fresh requests return 200, the limit trips after a burst) is consistent with what I observed. I overstated the case. The report should not be read as evidence of an IP- or account-level sticky limit.

    On the code paths: also right, and thanks for the catch. packages/opencode/src/session/retry.ts and packages/opencode/src/account/index.ts are v1. In v2 the device-code request is a single unguarded call in packages/core/src/plugin/provider/opencode.ts:

    const device = yield* post(
      http,
      `${server}/auth/device/code`,
      { client_id: clientID, supports_org_scope: true },
      Device,
    )

    No retry, no backoff, no Retry-After handling — a 429 simply ends the login. So my framing of "client backoff can only guess" does not apply to v2, and I have retitled the issue accordingly. The remaining ask is the smaller one you described: a Retry-After header would let the CLI print how long to wait instead of the current bare Authentication failed / StatusCode: non 2xx status code (429 …), which gives a subscriber no actionable signal.

    The reproduction script is useful, particularly the 15s probing loop — that is the part I could not measure.

    Related: #52227 (the Go API key discoverability issue this was detected through), #56498 (closed as its duplicate), #56500 (missing integration disconnect / DELETE route).

  3. changed the title [-]auth: Console device/code returns 429 with no Retry-After, so client backoff can only guess[/-] [+]auth: Console device/code returns 429 with no Retry-After, so the CLI cannot say how long to wait[/+] on Oct 17, 2026
  4. Dante-dan commented on Oct 17, 2026

    @Dante-dan
    Contributor

    I traced this at 86c7f7c: the public repository contains the device-code client in packages/core/src/plugin/provider/opencode.ts, but no implementation of the Console /auth/device/code handler or its RateLimited error. The remaining Console app redirects legacy auth routes to the new Console (middleware).

    Could a maintainer point to the repository/path owning that handler and its rate limiter? The fix needs the limiter’s actual remaining/reset duration when constructing the 429 response, so Retry-After reports the real window rather than a guessed client delay. The existing client tests exercise a local device-code stub; they cannot verify the deployed limiter’s reset metadata.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions