{"record":{"id":"e6bb2056947e74d1","repo":"charmbracelet/crush","slug":"token-request-failed-status-d-body-q","errorCode":null,"errorMessage":"token request failed: status %d body %q","messagePattern":"token request failed: status (.+?) body %q","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/oauth/hyper/device.go","lineNumber":146,"sourceCode":"\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn result, fmt.Errorf(\"execute request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))\n\tif err != nil {\n\t\treturn result, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn result, fmt.Errorf(\"unmarshal response: %w: %s\", err, string(body))\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn result, fmt.Errorf(\"token request failed: status %d body %q\", resp.StatusCode, string(body))\n\t}\n\n\treturn result, nil\n}\n\n// ExchangeToken exchanges a refresh token for an access token.\nfunc ExchangeToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {\n\treqBody := map[string]string{\n\t\t\"refresh_token\": refreshToken,\n\t}\n\n\tdata, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)\n\t}\n\n\turl := hyper.BaseURL() + \"/token/exchange\"\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))","sourceCodeStart":128,"sourceCodeEnd":164,"githubUrl":"https://github.com/charmbracelet/crush/blob/7944b8e52225d8805e31eacbf7ef24856b0dfb7a/internal/oauth/hyper/device.go#L128-L164","documentation":"This error means the Hyper token polling endpoint responded with valid JSON but a non-200 HTTP status. It carries the status code and the response body so the caller can see the server's explanation. This indicates an API-level rejection of the poll request itself (as opposed to the in-band authorization_pending/error fields carried in a 200 body).","triggerScenarios":"pollOnce GETs /device/auth/{deviceCode} and receives a status other than 200: the device_code is invalid/expired (404/400), the device code has been consumed, rate limiting (429) from polling too aggressively, or server errors (500/502/503) during Hyper API incidents.","commonSituations":"User waited past the device-code expiry window (expires_in) before completing browser authorization; the device code was already redeemed by another poller; a stale code reused from a previous login attempt; Hyper API outage or maintenance returning 5xx; aggressive polling triggering 429 rate limits.","solutions":["Restart the device-flow login (InitiateDeviceAuth) to get a fresh device code if the old one expired or was consumed","Wait for the authorization_pending interval and avoid polling faster than the server's interval to prevent 429s","Read the body in the error message for the server's specific rejection reason (expired, invalid, rate-limited)","Check the Hyper API status page / retry later if the status is 5xx (server-side incident)","Verify the deviceCode passed to PollForToken came from the current InitiateDeviceAuth call, not a cached old one"],"exampleFix":"// before: aborting on any non-200 during polling\nresult, err := pollOnce(ctx, deviceCode)\nif err != nil {\n    return \"\", err\n}\n// after: restart the flow when the device code is no longer valid\nresult, err := pollOnce(ctx, deviceCode)\nif err != nil {\n    if strings.Contains(err.Error(), \"status 404\") || strings.Contains(err.Error(), \"status 400\") {\n        return \"\", errors.New(\"device code expired or invalid; restart login\")\n    }\n    return \"\", err\n}","handlingStrategy":"validation","validationCode":"// Pre-flight: confirm the device code is fresh and polling within the expiry window\nif time.Since(authStarted) > time.Duration(expiresIn)*time.Second {\n    return errors.New(\"device code expired; restart InitiateDeviceAuth\")\n}\n// and throttle polling to respect the server interval:\ninterval := max(serverInterval, 5*time.Second)","typeGuard":"func isStatusError(err error, codes ...int) bool {\n    if err == nil {\n        return false\n    }\n    for _, c := range codes {\n        if strings.Contains(err.Error(), fmt.Sprintf(\"status %d \", c)) {\n            return true\n        }\n    }\n    return false\n}","tryCatchPattern":"result, err := pollOnce(ctx, deviceCode)\nif err != nil {\n    switch {\n    case isStatusError(err, 400, 404):\n        return \"\", errors.New(\"device code invalid or expired; restart device flow\")\n    case isStatusError(err, 429):\n        return retryWithBackoff(ctx, deviceCode) // respect rate limit\n    case isStatusError(err, 500, 502, 503):\n        return retryWithBackoff(ctx, deviceCode) // server-side, transient\n    default:\n        return \"\", err\n    }\n}","preventionTips":["Complete browser authorization before the expires_in window lapses","Never reuse device codes across login attempts — always start with InitiateDeviceAuth","Respect the server's polling interval (5s here) to avoid 429s","Parse the body from the error message for the server's exact rejection reason"],"tags":["http-status","api-error","oauth-device-flow","device-code-expired"],"backgroundTag":"http-non-200-response","analyzedSha":"7944b8e52225d8805e31eacbf7ef24856b0dfb7a","analyzedAt":"2026-08-29T12:48:59.079Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}