{"record":{"id":"ba3c426cd9b313cb","repo":"charmbracelet/crush","slug":"copilot-token-request-failed-s-s","errorCode":null,"errorMessage":"copilot token request failed: %s - %s","messagePattern":"copilot token request failed: (.+?) - (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/oauth/copilot/oauth.go","lineNumber":176,"sourceCode":"\t}\n\n\tclient := &http.Client{Timeout: 30 * time.Second}\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.StatusCode == http.StatusForbidden {\n\t\treturn nil, ErrNotAvailable\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"copilot token request failed: %s - %s\", resp.Status, string(body))\n\t}\n\n\tvar result struct {\n\t\tToken     string `json:\"token\"`\n\t\tExpiresAt int64  `json:\"expires_at\"`\n\t}\n\tif err := json.Unmarshal(body, &result); err != nil {\n\t\treturn nil, err\n\t}\n\n\tcopilotToken := &oauth.Token{\n\t\tAccessToken:  result.Token,\n\t\tRefreshToken: githubToken,\n\t\tExpiresAt:    result.ExpiresAt,\n\t}\n\tcopilotToken.SetExpiresIn()\n\n\treturn copilotToken, nil","sourceCodeStart":158,"sourceCodeEnd":194,"githubUrl":"https://github.com/charmbracelet/crush/blob/7944b8e52225d8805e31eacbf7ef24856b0dfb7a/internal/oauth/copilot/oauth.go#L158-L194","documentation":"getCopilotToken exchanges the GitHub OAuth token for a short-lived Copilot API token. A 403 maps to ErrNotAvailable (plan/entitlement missing), but any other non-200 status yields \"copilot token request failed: <status> - <body>\" with the raw response body. Called by tryGetToken (initial login) and RefreshToken (token renewal).","triggerScenarios":"The GET to the Copilot token endpoint returns e.g. 401 (GitHub token invalid/expired/revoked), 404 (endpoint moved), or 5xx (service outage). 403 is intentionally handled separately as ErrNotAvailable.","commonSituations":"GitHub session token revoked or expired before refresh; user's Copilot subscription lapsed (may surface as non-403 on some paths); corporate proxy blocking the endpoint; transient GitHub 5xx during RefreshToken causing spurious logout.","solutions":["Read status/body in the error: 401 means re-run the full GitHub device-flow login to get a new token","Retry with backoff on 5xx — especially in RefreshToken, where a transient failure should not force re-auth","Verify the Copilot subscription/entitlement is active for the account","Check proxy/firewall access to the Copilot token endpoint"],"exampleFix":"// before\ntok, err := RefreshToken(ctx, t)\nif err != nil { return err }\n// after\ntok, err := RefreshToken(ctx, t)\nif err != nil {\n    if strings.Contains(err.Error(), \"50\") {\n        time.Sleep(backoff)\n        tok, err = RefreshToken(ctx, t)\n    }\n    if err != nil { return err }\n}","handlingStrategy":"retry","validationCode":"func needsRelogin(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"copilot token request failed: 401\")\n}","typeGuard":null,"tryCatchPattern":"tok, err := RefreshToken(ctx, t)\nif err != nil {\n    if needsRelogin(err) {\n        return fullDeviceFlowLogin(ctx) // 401: token dead, re-auth\n    }\n    if strings.Contains(err.Error(), \"50\") {\n        time.Sleep(backoff)\n        tok, err = RefreshToken(ctx, t)\n    }\n}","preventionTips":["Treat 401 as re-auth, 5xx as retry, 403 as ErrNotAvailable (no subscription)","Refresh the Copilot token proactively before expires_at","Retry refreshes with backoff before invalidating stored credentials"],"tags":["oauth","copilot","http","token-refresh","github"],"backgroundTag":"copilot-token-request-failed","analyzedSha":"7944b8e52225d8805e31eacbf7ef24856b0dfb7a","analyzedAt":"2026-08-29T12:48:59.079Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}