charmbracelet/crush · error

failed to decode response: %w

Error message

failed to decode response: %w

What it means

FetchCredits decodes the /v1/credits response body into a struct with a single nullable `balance` int field; a JSON syntax error, non-JSON body (HTML error page, empty body), or a type mismatch (e.g. balance as a float/string) triggers this wrapped error. Note the endpoint normally returns 200 JSON, so this indicates a malformed or unexpected payload.

Source

Thrown at internal/agent/hyper/provider.go:108

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to make request: %w", err)
	}
	defer resp.Body.Close() //nolint:errcheck

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	// Teams with hypercredit display disabled get a balance_usd field
	// instead of balance, and no balance is shown for them at all.
	var result struct {
		Balance *int `json:"balance"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("failed to decode response: %w", err)
	}

	return result.Balance, nil
}

View on GitHub (pinned to 7944b8e522)

Solutions

  1. Capture the raw body (`curl -s $HYPER_URL/v1/credits | head -c 500`) and confirm it is valid JSON with an integer `balance` field.
  2. If behind a custom HYPER_URL proxy, fix it to forward Hyper's JSON response verbatim (disable HTML error pages with 200 status).
  3. If balance arrives as a float (e.g. 12.5), fix the server payload to emit an integer, or decode into *float64 client-side before converting.
  4. Check for transparent proxies/antivirus that rewrite response bodies.

Example fix

// before (server payload mismatch)
// {"balance": 12.5} -> decode into *int fails: "failed to decode response"
// after (client-side tolerant decode)
var raw struct {
    Balance json.Number `json:"balance"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
    return nil, fmt.Errorf("failed to decode response: %w", err)
}
if raw.Balance.String() == "" {
    return nil, nil // hypercredit display disabled
}
b, err := raw.Balance.Int64()
Defensive patterns

Strategy: type-guard

Validate before calling

resp, err := http.Get(hyper.BaseURL() + "/v1/credits")
if err == nil {
    ct := resp.Header.Get("Content-Type")
    if !strings.Contains(ct, "application/json") {
        return fmt.Errorf("expected JSON, got %q", ct)
    }
    io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
}

Type guard

func validCreditsPayload(body []byte) bool {
    var v struct {
        Balance *int `json:"balance"`
    }
    if json.Unmarshal(body, &v) != nil {
        return false
    }
    return v.Balance == nil || *v.Balance >= 0
}

Try / catch

balance, err := hyper.FetchCredits(ctx, apiKey)
if err != nil {
    if strings.Contains(err.Error(), "failed to decode response") {
        log.Warn("malformed credits payload from proxy; check HYPER_URL target", "err", err)
        return nil, nil // treat as no balance rather than crashing UI
    }
    return nil, err
}

Prevention

When it happens

Trigger: Proxy at HYPER_URL returns an HTML login/error page with status 200; response body is truncated or empty; server sends JSON with `balance` as a non-integer (e.g. 12.5 or "100"); gzip/encoding mismatch handled incorrectly by an intermediate proxy.

Common situations: Self-hosted HYPER_URL proxies that return 200 with an auth-portal HTML page; a proxy that rewrites the payload shape (balance in cents as float64); CDN interference corrupting the body.

Understand the failure class

Related errors


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