charmbracelet/crush · error
token request failed: status %d body %q
Error message
token request failed: status %d body %q
What it means
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).
Source
Thrown at internal/oauth/hyper/device.go:146
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return result, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return result, fmt.Errorf("read response: %w", err)
}
if err := json.Unmarshal(body, &result); err != nil {
return result, fmt.Errorf("unmarshal response: %w: %s", err, string(body))
}
if resp.StatusCode != http.StatusOK {
return result, fmt.Errorf("token request failed: status %d body %q", resp.StatusCode, string(body))
}
return result, nil
}
// ExchangeToken exchanges a refresh token for an access token.
func ExchangeToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {
reqBody := map[string]string{
"refresh_token": refreshToken,
}
data, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
url := hyper.BaseURL() + "/token/exchange"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))View on GitHub (pinned to 7944b8e522)
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
Example fix
// before: aborting on any non-200 during polling
result, err := pollOnce(ctx, deviceCode)
if err != nil {
return "", err
}
// after: restart the flow when the device code is no longer valid
result, err := pollOnce(ctx, deviceCode)
if err != nil {
if strings.Contains(err.Error(), "status 404") || strings.Contains(err.Error(), "status 400") {
return "", errors.New("device code expired or invalid; restart login")
}
return "", err
} Defensive patterns
Strategy: validation
Validate before calling
// Pre-flight: confirm the device code is fresh and polling within the expiry window
if time.Since(authStarted) > time.Duration(expiresIn)*time.Second {
return errors.New("device code expired; restart InitiateDeviceAuth")
}
// and throttle polling to respect the server interval:
interval := max(serverInterval, 5*time.Second) Type guard
func isStatusError(err error, codes ...int) bool {
if err == nil {
return false
}
for _, c := range codes {
if strings.Contains(err.Error(), fmt.Sprintf("status %d ", c)) {
return true
}
}
return false
} Try / catch
result, err := pollOnce(ctx, deviceCode)
if err != nil {
switch {
case isStatusError(err, 400, 404):
return "", errors.New("device code invalid or expired; restart device flow")
case isStatusError(err, 429):
return retryWithBackoff(ctx, deviceCode) // respect rate limit
case isStatusError(err, 500, 502, 503):
return retryWithBackoff(ctx, deviceCode) // server-side, transient
default:
return "", err
}
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- failed to list skills: status code %d
- failed to read skill: status code %d
- failed to enable docker MCP: status code %d
- failed to disable docker MCP: status code %d
- failed to refresh OAuth token: status code %d
AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29).
Data as JSON: /api/errors/e6bb2056947e74d1.
Report an issue: GitHub.