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

  1. Restart the device-flow login (InitiateDeviceAuth) to get a fresh device code if the old one expired or was consumed
  2. Wait for the authorization_pending interval and avoid polling faster than the server's interval to prevent 429s
  3. Read the body in the error message for the server's specific rejection reason (expired, invalid, rate-limited)
  4. Check the Hyper API status page / retry later if the status is 5xx (server-side incident)
  5. 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

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


AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29). Data as JSON: /api/errors/e6bb2056947e74d1. Report an issue: GitHub.