{"record":{"id":"a24a22628ace24e0","repo":"charmbracelet/crush","slug":"unmarshal-response-w-s","errorCode":null,"errorMessage":"unmarshal response: %w: %s","messagePattern":"unmarshal response: %w: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/oauth/hyper/device.go","lineNumber":142,"sourceCode":"\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"crush\")\n\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn result, fmt.Errorf(\"execute request: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))\n\tif err != nil {\n\t\treturn result, fmt.Errorf(\"read response: %w\", err)\n\t}\n\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn result, fmt.Errorf(\"unmarshal response: %w: %s\", err, string(body))\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn result, fmt.Errorf(\"token request failed: status %d body %q\", resp.StatusCode, string(body))\n\t}\n\n\treturn result, nil\n}\n\n// ExchangeToken exchanges a refresh token for an access token.\nfunc ExchangeToken(ctx context.Context, refreshToken string) (*oauth.Token, error) {\n\treqBody := map[string]string{\n\t\t\"refresh_token\": refreshToken,\n\t}\n\n\tdata, err := json.Marshal(reqBody)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"marshal request: %w\", err)","sourceCodeStart":124,"sourceCodeEnd":160,"githubUrl":"https://github.com/charmbracelet/crush/blob/7944b8e52225d8805e31eacbf7ef24856b0dfb7a/internal/oauth/hyper/device.go#L124-L160","documentation":"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).","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before (current behavior in pollOnce): unmarshal before status check,\n// so HTML error pages land in 'unmarshal response'\nif err := json.Unmarshal(body, &result); err != nil {\n    return result, fmt.Errorf(\"unmarshal response: %w: %s\", err, string(body))\n}\n// after: check status first so gateway error pages report as status errors\nif resp.StatusCode != http.StatusOK {\n    return result, fmt.Errorf(\"token request failed: status %d body %q\", resp.StatusCode, string(body))\n}\nif err := json.Unmarshal(body, &result); err != nil {\n    return result, fmt.Errorf(\"unmarshal response: %w: %s\", err, string(body))\n}","handlingStrategy":"validation","validationCode":"// Validate the response looks like JSON before unmarshalling\nbody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))\nct := resp.Header.Get(\"Content-Type\")\ntrimmed := bytes.TrimSpace(body)\nif resp.StatusCode != http.StatusOK ||\n    (!strings.Contains(ct, \"application/json\") && (len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '['))) {\n    return result, fmt.Errorf(\"non-JSON response: status %d content-type %q body %q\", resp.StatusCode, ct, string(body))\n}","typeGuard":"func isUnmarshalError(err error) bool {\n    var jsonErr *json.UnmarshalTypeError\n    return err != nil && (strings.HasPrefix(err.Error(), \"unmarshal response:\") || errors.As(err, &jsonErr))\n}","tryCatchPattern":"if err := json.Unmarshal(body, &result); err != nil {\n    var synErr *json.SyntaxError\n    if errors.As(err, &synErr) {\n        // Non-JSON payload (HTML error page, empty body): inspect synErr.Error() plus the raw body\n        return result, fmt.Errorf(\"non-JSON payload at offset %d: %q\", synErr.Offset, string(body))\n    }\n    return result, fmt.Errorf(\"unmarshal response: %w: %s\", err, string(body))\n}","preventionTips":["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"],"tags":["json","unmarshal","http-response","oauth-device-flow"],"backgroundTag":"json-unmarshal-failed","analyzedSha":"7944b8e52225d8805e31eacbf7ef24856b0dfb7a","analyzedAt":"2026-08-29T12:48:59.079Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}