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

  1. Read the body excerpt appended to the error message — it reveals what was actually returned (HTML vs empty vs garbage)
  2. Verify hyper.BaseURL() points to the correct Hyper API host, not a proxy or web frontend
  3. Bypass any corporate proxy/captive portal and retry the device login
  4. Check whether the server is returning an error page with a non-200 status and non-JSON body, and fix server/gateway side
  5. 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

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


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