{"record":{"id":"17dbbe7641354037","repo":"sipeed/picoclaw","slug":"reading-token-exchange-response-w","errorCode":null,"errorMessage":"reading token exchange response: %w","messagePattern":"reading token exchange response: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/auth/oauth.go","lineNumber":562,"sourceCode":"\tif cfg.TokenURL != \"\" {\n\t\ttokenURL = cfg.TokenURL\n\t}\n\n\t// Determine provider name from config\n\tprovider := \"openai\"\n\tif cfg.TokenURL != \"\" && strings.Contains(cfg.TokenURL, \"googleapis.com\") {\n\t\tprovider = \"google-antigravity\"\n\t}\n\n\tresp, err := http.PostForm(tokenURL, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"exchanging code for tokens: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading token exchange response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token exchange failed: %s\", string(body))\n\t}\n\n\treturn parseTokenResponse(body, provider)\n}\n\nfunc parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn    int    `json:\"expires_in\"`\n\t\tIDToken      string `json:\"id_token\"`\n\t}\n\tif err := json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing token response: %w\", err)\n\t}","sourceCodeStart":544,"sourceCodeEnd":580,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/auth/oauth.go#L544-L580","documentation":"ExchangeCodeForTokens (pkg/auth/oauth.go:562) received an HTTP response from the token endpoint but io.ReadAll failed streaming it, so a successfully issued token set was lost. Note the code is single-use: a naive retry with the same code can fail with invalid_grant.","triggerScenarios":"Connection reset or context timeout after status 200 but before the token JSON is fully read on the /oauth/token POST.","commonSituations":"Flaky networks at the worst moment of the flow; aggressive client timeouts; proxy truncation. Rarer than transport or status failures but nastier because the one-time code may already be consumed.","solutions":["Retry once with the same code verifier — if the provider says invalid_grant, the code was consumed and the whole login must restart","Increase http.Client Timeout or read the body with a bounded context","Check intermediary proxies for stream truncation","Persist tokens immediately after a successful exchange to narrow the loss window"],"exampleFix":null,"handlingStrategy":"retry","validationCode":null,"typeGuard":"func isBodyReadError(err error) bool {\n\treturn err != nil && strings.Contains(err.Error(), \"reading token exchange response\")\n}","tryCatchPattern":"cred, err := auth.ExchangeCodeForTokens(cfg, code, verifier, redirectURI)\nif err != nil && isBodyReadError(err) {\n\t// code may already be consumed server-side: retry once, restart on invalid_grant\n\tcred, err = auth.ExchangeCodeForTokens(cfg, code, verifier, redirectURI)\n\tif err != nil && strings.Contains(err.Error(), \"invalid_grant\") {\n\t\treturn reloginFlow(cfg)\n\t}\n}","preventionTips":["Treat a lost token-exchange body as 'possibly consumed code' — plan the recovery path","Increase client timeout for the exchange call","Persist credentials immediately after success","Avoid user-visible interruption between callback and exchange"],"tags":["network","io","transient","authorization-code","oauth","go"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}