charmbracelet/crush · error
unmarshal response: %w: %s
Error message
unmarshal response: %w: %s
What it means
This error means the body returned by the Hyper token polling endpoint could not be parsed as JSON into TokenResponse. The library includes the raw body in the message (%s) precisely because non-JSON payloads usually signal a proxy, gateway, or server returning HTML/text instead of the expected JSON. It is a response-shape mismatch, not an OAuth error (OAuth errors still unmarshal into result.Error).
Source
Thrown at internal/oauth/hyper/device.go:142
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "crush")
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)View on GitHub (pinned to 7944b8e522)
Solutions
- Read the body excerpt appended to the error message — it reveals what was actually returned (HTML vs empty vs garbage)
- Verify hyper.BaseURL() points to the correct Hyper API host, not a proxy or web frontend
- Bypass any corporate proxy/captive portal and retry the device login
- Check whether the server is returning an error page with a non-200 status and non-JSON body, and fix server/gateway side
- Note the check order in pollOnce: unmarshal happens BEFORE the status-code check, so even 5xx HTML/empty bodies surface here — confirm the endpoint's actual status with curl
Example fix
// before (current behavior in pollOnce): unmarshal before status check,
// so HTML error pages land in 'unmarshal response'
if err := json.Unmarshal(body, &result); err != nil {
return result, fmt.Errorf("unmarshal response: %w: %s", err, string(body))
}
// after: check status first so gateway error pages report as status errors
if resp.StatusCode != http.StatusOK {
return result, fmt.Errorf("token request failed: status %d body %q", resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, &result); err != nil {
return result, fmt.Errorf("unmarshal response: %w: %s", err, string(body))
} Defensive patterns
Strategy: validation
Validate before calling
// Validate the response looks like JSON before unmarshalling
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
ct := resp.Header.Get("Content-Type")
trimmed := bytes.TrimSpace(body)
if resp.StatusCode != http.StatusOK ||
(!strings.Contains(ct, "application/json") && (len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '['))) {
return result, fmt.Errorf("non-JSON response: status %d content-type %q body %q", resp.StatusCode, ct, string(body))
} Type guard
func isUnmarshalError(err error) bool {
var jsonErr *json.UnmarshalTypeError
return err != nil && (strings.HasPrefix(err.Error(), "unmarshal response:") || errors.As(err, &jsonErr))
} Try / catch
if err := json.Unmarshal(body, &result); err != nil {
var synErr *json.SyntaxError
if errors.As(err, &synErr) {
// Non-JSON payload (HTML error page, empty body): inspect synErr.Error() plus the raw body
return result, fmt.Errorf("non-JSON payload at offset %d: %q", synErr.Offset, string(body))
}
return result, fmt.Errorf("unmarshal response: %w: %s", err, string(body))
} Prevention
- Check Content-Type and status code before parsing JSON
- Verify BaseURL points at the API, not a web frontend or proxy portal
- Detect captive portals / proxy-injected HTML pages in corporate networks
- Keep the raw body in the error message (as the library does) for quick diagnosis
When it happens
Trigger: json.Unmarshal of the poll response body fails: an HTML error page from a proxy/gateway (502/503 pages), an empty body from a 204 or an intercepting middlebox, a wrong BaseURL pointing at a non-API host, or the server returning a non-JSON content type.
Common situations: Corporate proxy or captive portal injecting an HTML login page; misconfigured hyper.BaseURL (e.g. pointing at a website root that returns HTML); API returning an empty body on unusual status codes; TLS-inspecting middlebox rewriting responses; API contract change on a staging environment.
Related errors
- questions must be an array: %w
- failed to unmarshal provider data: %w
- failed to decode response: %w
- error parsing parameters: %s
- failed to marshal GraphQL request: %w
AI-assisted analysis of charmbracelet/crush@7944b8e522 (2026-08-29).
Data as JSON: /api/errors/a24a22628ace24e0.
Report an issue: GitHub.