Repository navigation
Description
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
opencode-agent commentedon Oct 17, 2026
Thanks for the detailed report. I reproduced it.
POST https://opencode.ai/console/auth/device/codereturned200.429with{"_tag":"RateLimited","message":"Too many requests, please try again later"}.429had noRetry-Afterorretry-after-msheader, even though the response listsretry-afterinaccess-control-expose-headers. The limit lasted well over a minute.opencode auth login opencode --method devicefailed straight away withAuthentication failed/StatusCode: non 2xx status code (429 POST https://opencode.ai/console/auth/device/code). That happened on 2.0.29 and on the latestdev(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:
429every time with no burst before it. My fresh requests succeeded until I sent the burst.packages/opencode/src/session/retry.ts,account/index.ts) are v1 code. In v2 the device-code request is made frompackages/core/src/plugin/provider/opencode.ts, and it doesn't retry: a429just ends the login. So aRetry-Afterheader from the Console would mainly let the CLI tell you how long to wait.Related: #56498 and #52227.
Reproduction script (repro.sh)
Run it:
bash repro.shlucasvidela94 commentedon Oct 17, 2026
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
429was "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/coderepeatedly — testing form-encoded against JSON, checkingRetry-After, and confirming the 401 on the old key. So my "single client" was not a cold client, and your finding (fresh requests return200, 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.tsandpackages/opencode/src/account/index.tsare v1. In v2 the device-code request is a single unguarded call inpackages/core/src/plugin/provider/opencode.ts:No retry, no backoff, no
Retry-Afterhandling — a429simply 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: aRetry-Afterheader would let the CLI print how long to wait instead of the current bareAuthentication 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 /
DELETEroute).[-]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[/+]Dante-dan commentedon Oct 17, 2026
I traced this at
86c7f7c: the public repository contains the device-code client inpackages/core/src/plugin/provider/opencode.ts, but no implementation of the Console/auth/device/codehandler or itsRateLimitederror. 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-Afterreports 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.