fix(ccm): align OAuth token refresh with Claude Code v2.1.81
After re-login with newer Claude Code (v2.1.75+), CCM refresh requests returned persistent 429s. Root cause: CCM omitted the `scope` parameter that the server now requires for tokens with `user:file_upload` scope. Changes to fully match Claude Code's OAuth behavior: - Add `scope` parameter to token refresh request body - Parse `scope` from refresh response and store back - Add `subscriptionType`/`rateLimitTier` to credential struct to preserve Claude Code's profile state on write-back - Change credential file write to read-modify-write, preserving other top-level JSON keys (matches Claude Code's BP6 pattern) - Same for macOS keychain write path - Increase token expiry buffer from 1 min to 5 min (matching CC's isOAuthTokenExpired with 300s buffer) - Add cross-process mkdir-based file lock compatible with Claude Code's proper-lockfile protocol (~/.claude.lock) - Add post-failure recovery: re-read credentials from disk after refresh failure in case another process succeeded - Add 401/403 "OAuth token has been revoked" recovery in proxy handler: reload credentials and retry once
This commit is contained in:
@@ -76,42 +76,66 @@ func platformCanWriteCredentials(customPath string) error {
|
||||
return checkCredentialFileWritable(customPath)
|
||||
}
|
||||
|
||||
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
|
||||
// platformWriteCredentials performs a read-modify-write on the keychain entry,
|
||||
// preserving any fields or top-level keys not managed by CCM.
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179444-179454) — read-modify-write
|
||||
func platformWriteCredentials(credentials *oauthCredentials, customPath string) error {
|
||||
if customPath != "" {
|
||||
return writeCredentialsToFile(oauthCredentials, customPath)
|
||||
return writeCredentialsToFile(credentials, customPath)
|
||||
}
|
||||
|
||||
userInfo, err := getRealUser()
|
||||
if err == nil {
|
||||
data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials})
|
||||
serviceName := getKeychainServiceName()
|
||||
|
||||
existing := make(map[string]json.RawMessage)
|
||||
query := keychain.NewItem()
|
||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||
query.SetService(serviceName)
|
||||
query.SetAccount(userInfo.Username)
|
||||
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||
query.SetReturnData(true)
|
||||
results, queryErr := keychain.QueryItem(query)
|
||||
if queryErr == nil && len(results) == 1 {
|
||||
_ = json.Unmarshal(results[0].Data, &existing)
|
||||
}
|
||||
|
||||
credentialData, err := json.Marshal(credentials)
|
||||
if err != nil {
|
||||
return E.Cause(err, "marshal credentials")
|
||||
}
|
||||
existing["claudeAiOauth"] = credentialData
|
||||
data, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return E.Cause(err, "marshal credential container")
|
||||
}
|
||||
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(serviceName)
|
||||
item.SetAccount(userInfo.Username)
|
||||
item.SetData(data)
|
||||
item.SetAccessible(keychain.AccessibleWhenUnlocked)
|
||||
|
||||
err = keychain.AddItem(item)
|
||||
if err == nil {
|
||||
serviceName := getKeychainServiceName()
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
item.SetService(serviceName)
|
||||
item.SetAccount(userInfo.Username)
|
||||
item.SetData(data)
|
||||
item.SetAccessible(keychain.AccessibleWhenUnlocked)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = keychain.AddItem(item)
|
||||
if err == nil {
|
||||
if err == keychain.ErrorDuplicateItem {
|
||||
updateQuery := keychain.NewItem()
|
||||
updateQuery.SetSecClass(keychain.SecClassGenericPassword)
|
||||
updateQuery.SetService(serviceName)
|
||||
updateQuery.SetAccount(userInfo.Username)
|
||||
|
||||
updateItem := keychain.NewItem()
|
||||
updateItem.SetData(data)
|
||||
|
||||
updateErr := keychain.UpdateItem(updateQuery, updateItem)
|
||||
if updateErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == keychain.ErrorDuplicateItem {
|
||||
query := keychain.NewItem()
|
||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||
query.SetService(serviceName)
|
||||
query.SetAccount(userInfo.Username)
|
||||
|
||||
updateItem := keychain.NewItem()
|
||||
updateItem.SetData(data)
|
||||
|
||||
updateErr := keychain.UpdateItem(query, updateItem)
|
||||
if updateErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,5 +143,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCredentialsToFile(oauthCredentials, defaultPath)
|
||||
return writeCredentialsToFile(credentials, defaultPath)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,6 +30,7 @@ type defaultCredential struct {
|
||||
serviceContext context.Context
|
||||
credentialPath string
|
||||
credentialFilePath string
|
||||
configDir string
|
||||
statePath string
|
||||
credentials *oauthCredentials
|
||||
access sync.RWMutex
|
||||
@@ -132,6 +134,7 @@ func (c *defaultCredential) start() error {
|
||||
return E.Cause(err, "resolve credential path for ", c.tag)
|
||||
}
|
||||
c.credentialFilePath = credentialFilePath
|
||||
c.configDir = resolveConfigDir(c.credentialPath, credentialFilePath)
|
||||
c.loadPersistedState()
|
||||
err = c.ensureCredentialWatcher()
|
||||
if err != nil {
|
||||
@@ -176,6 +179,7 @@ func (c *defaultCredential) statusSnapshotLocked() statusSnapshot {
|
||||
func (c *defaultCredential) getAccessToken() (string, error) {
|
||||
c.retryCredentialReloadIfNeeded()
|
||||
|
||||
// Fast path: cached token is still valid
|
||||
c.access.RLock()
|
||||
if c.credentials != nil && !c.credentials.needsRefresh() {
|
||||
token := c.credentials.AccessToken
|
||||
@@ -184,6 +188,7 @@ func (c *defaultCredential) getAccessToken() (string, error) {
|
||||
}
|
||||
c.access.RUnlock()
|
||||
|
||||
// Reload from disk — Claude Code or another process may have refreshed
|
||||
err := c.reloadCredentials(true)
|
||||
if err == nil {
|
||||
c.access.RLock()
|
||||
@@ -195,6 +200,41 @@ func (c *defaultCredential) getAccessToken() (string, error) {
|
||||
c.access.RUnlock()
|
||||
}
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js _P1 line 179526
|
||||
// Claude Code skips refresh for tokens without user:inference scope.
|
||||
// Return existing token (may be expired); 401 recovery is the safety net.
|
||||
c.access.RLock()
|
||||
if c.credentials != nil && !slices.Contains(c.credentials.Scopes, "user:inference") {
|
||||
token := c.credentials.AccessToken
|
||||
c.access.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
c.access.RUnlock()
|
||||
|
||||
// Acquire cross-process lock before refresh (outside Go mutex to avoid holding mutex during sleep)
|
||||
// ref: cli.js _P1 (line 179534-179536) — proper-lockfile lock on config dir
|
||||
release, lockErr := acquireCredentialLock(c.configDir)
|
||||
if lockErr != nil {
|
||||
c.logger.Debug("acquire credential lock for ", c.tag, ": ", lockErr)
|
||||
release = func() {}
|
||||
}
|
||||
defer release()
|
||||
|
||||
// ref: cli.js _P1 (line 179559-179562) — re-read after lock, skip if race resolved
|
||||
_ = c.reloadCredentials(true)
|
||||
c.access.RLock()
|
||||
noRefreshToken := c.credentials == nil || c.credentials.RefreshToken == ""
|
||||
raceResolved := !noRefreshToken && !c.credentials.needsRefresh()
|
||||
var racedToken string
|
||||
if (noRefreshToken || raceResolved) && c.credentials != nil {
|
||||
racedToken = c.credentials.AccessToken
|
||||
}
|
||||
c.access.RUnlock()
|
||||
if noRefreshToken || raceResolved {
|
||||
return racedToken, nil
|
||||
}
|
||||
|
||||
// Slow path: acquire Go mutex and refresh
|
||||
c.access.Lock()
|
||||
defer c.access.Unlock()
|
||||
|
||||
@@ -227,6 +267,14 @@ func (c *defaultCredential) getAccessToken() (string, error) {
|
||||
c.refreshRetryAt = time.Now().Add(retryDelay)
|
||||
c.refreshRetryError = err
|
||||
}
|
||||
// ref: cli.js _P1 (line 179568-179573) — post-failure recovery:
|
||||
// re-read from disk; if another process refreshed successfully, use that.
|
||||
// Cannot call reloadCredentials here (deadlock: already holding c.access).
|
||||
latestCredentials, readErr := platformReadCredentials(c.credentialPath)
|
||||
if readErr == nil && latestCredentials != nil && !latestCredentials.needsRefresh() {
|
||||
c.credentials = latestCredentials
|
||||
return latestCredentials.AccessToken, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
c.refreshRetryAt = time.Time{}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
// acquireCredentialLock acquires a cross-process lock compatible with Claude Code's
|
||||
// proper-lockfile protocol. The lock is a directory created via mkdir (atomic on
|
||||
// POSIX filesystems).
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js _P1 (line 179530-179577)
|
||||
// ref: proper-lockfile mkdir protocol (cli.js:43570)
|
||||
// ref: proper-lockfile default options — stale=10s, update=stale/2=5s, realpath=true (cli.js:43661-43664)
|
||||
//
|
||||
// Claude Code locks d1() (= ~/.claude config dir). The lock directory is
|
||||
// <realpath(configDir)>.lock (proper-lockfile default: <path>.lock).
|
||||
// Manual retry: initial + 5 retries = 6 total, delay 1+rand(1s) per retry.
|
||||
func acquireCredentialLock(configDir string) (func(), error) {
|
||||
// ref: cli.js _P1 line 179531 — mkdir -p configDir before locking
|
||||
os.MkdirAll(configDir, 0o700)
|
||||
// ref: proper-lockfile realpath:true (cli.js:43664) — resolve symlinks before appending .lock
|
||||
resolved, err := filepath.EvalSymlinks(configDir)
|
||||
if err != nil {
|
||||
resolved = filepath.Clean(configDir)
|
||||
}
|
||||
lockPath := resolved + ".lock"
|
||||
// ref: cli.js _P1 line 179539-179543 — initial + 5 retries = 6 total attempts
|
||||
for attempt := 0; attempt < 6; attempt++ {
|
||||
if attempt > 0 {
|
||||
// ref: cli.js _P1 line 179542 — 1000 + Math.random() * 1000
|
||||
delay := time.Second + time.Duration(rand.IntN(1000))*time.Millisecond
|
||||
time.Sleep(delay)
|
||||
}
|
||||
err = os.Mkdir(lockPath, 0o755)
|
||||
if err == nil {
|
||||
return startLockHeartbeat(lockPath), nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return nil, E.Cause(err, "create lock directory")
|
||||
}
|
||||
// ref: proper-lockfile stale check (cli.js:43603-43604)
|
||||
// stale threshold = 10s (cli.js:43662)
|
||||
info, statErr := os.Stat(lockPath)
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
if time.Since(info.ModTime()) > 10*time.Second {
|
||||
os.Remove(lockPath)
|
||||
}
|
||||
}
|
||||
return nil, E.New("credential lock timeout")
|
||||
}
|
||||
|
||||
// startLockHeartbeat spawns a goroutine that touches the lock directory's mtime
|
||||
// every 5 seconds to prevent stale detection by other processes.
|
||||
//
|
||||
// ref: proper-lockfile update interval = stale/2 = 5s (cli.js:43662-43663)
|
||||
//
|
||||
// Returns a release function that stops the heartbeat and removes the lock directory.
|
||||
func startLockHeartbeat(lockPath string) func() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
os.Chtimes(lockPath, now, now)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(done)
|
||||
os.Remove(lockPath)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -23,10 +24,32 @@ const (
|
||||
oauth2ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
oauth2TokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||
claudeAPIBaseURL = "https://api.anthropic.com"
|
||||
tokenRefreshBufferMs = 60000
|
||||
anthropicBetaOAuthValue = "oauth-2025-04-20"
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js vB (line 172879)
|
||||
tokenRefreshBufferMs = 300000
|
||||
)
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js q78 (line 33167)
|
||||
// These scopes may change across Claude Code versions.
|
||||
var defaultOAuthScopes = []string{
|
||||
"user:profile", "user:inference", "user:sessions:claude_code",
|
||||
"user:mcp_servers", "user:file_upload",
|
||||
}
|
||||
|
||||
// resolveRefreshScopes determines which scopes to send in the token refresh request.
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js NR() (line 172693) + mB6 scope logic (line 172761)
|
||||
//
|
||||
// Claude Code behavior: if stored scopes include "user:inference", send default
|
||||
// scopes; otherwise send the stored scopes verbatim.
|
||||
func resolveRefreshScopes(stored []string) string {
|
||||
if len(stored) == 0 || slices.Contains(stored, "user:inference") {
|
||||
return strings.Join(defaultOAuthScopes, " ")
|
||||
}
|
||||
return strings.Join(stored, " ")
|
||||
}
|
||||
|
||||
const ccmUserAgentFallback = "claude-code/2.1.72"
|
||||
|
||||
var (
|
||||
@@ -71,6 +94,22 @@ func detectClaudeCodeVersion() (string, error) {
|
||||
return filepath.Base(target), nil
|
||||
}
|
||||
|
||||
// resolveConfigDir returns the Claude config directory for lock coordination.
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js d1() (line 2983) — config dir used for locking
|
||||
func resolveConfigDir(credentialPath string, credentialFilePath string) string {
|
||||
if credentialPath == "" {
|
||||
if configDir := os.Getenv("CLAUDE_CONFIG_DIR"); configDir != "" {
|
||||
return configDir
|
||||
}
|
||||
userInfo, err := getRealUser()
|
||||
if err == nil {
|
||||
return filepath.Join(userInfo.HomeDir, ".claude")
|
||||
}
|
||||
}
|
||||
return filepath.Dir(credentialFilePath)
|
||||
}
|
||||
|
||||
func getRealUser() (*user.User, error) {
|
||||
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||
sudoUserInfo, err := user.Lookup(sudoUser)
|
||||
@@ -118,10 +157,24 @@ func checkCredentialFileWritable(path string) error {
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error {
|
||||
data, err := json.MarshalIndent(map[string]any{
|
||||
"claudeAiOauth": oauthCredentials,
|
||||
}, "", " ")
|
||||
// writeCredentialsToFile performs a read-modify-write: reads the existing JSON,
|
||||
// replaces only the claudeAiOauth key, and writes back. This preserves any
|
||||
// other top-level keys in the credential file.
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179444-179454) — read-modify-write
|
||||
// ref: cli.js qD1.update (line 176156) — writeFileSync + chmod 0o600
|
||||
func writeCredentialsToFile(credentials *oauthCredentials, path string) error {
|
||||
existing := make(map[string]json.RawMessage)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr == nil {
|
||||
_ = json.Unmarshal(data, &existing)
|
||||
}
|
||||
credentialData, err := json.Marshal(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing["claudeAiOauth"] = credentialData
|
||||
data, err = json.MarshalIndent(existing, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,16 +184,14 @@ func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) err
|
||||
// oauthCredentials mirrors the claudeAiOauth object in Claude Code's
|
||||
// credential file ($CLAUDE_CONFIG_DIR/.credentials.json).
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6() / refreshOAuthToken
|
||||
//
|
||||
// Note: subscriptionType, rateLimitTier, and isMax were removed from this
|
||||
// struct — they are profile state, not auth credentials. Claude Code also
|
||||
// stores them here, but we persist them separately via state_path instead.
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179446-179452)
|
||||
type oauthCredentials struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
AccessToken string `json:"accessToken"` // ref: cli.js line 179447
|
||||
RefreshToken string `json:"refreshToken"` // ref: cli.js line 179448
|
||||
ExpiresAt int64 `json:"expiresAt"` // ref: cli.js line 179449 (epoch ms)
|
||||
Scopes []string `json:"scopes"` // ref: cli.js line 179450
|
||||
SubscriptionType *string `json:"subscriptionType"` // ref: cli.js line 179451 (?? null)
|
||||
RateLimitTier *string `json:"rateLimitTier"` // ref: cli.js line 179452 (?? null)
|
||||
}
|
||||
|
||||
func (c *oauthCredentials) needsRefresh() bool {
|
||||
@@ -155,10 +206,12 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau
|
||||
return nil, 0, E.New("refresh token is empty")
|
||||
}
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6 (line 172757-172761)
|
||||
requestBody, err := json.Marshal(map[string]string{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": credentials.RefreshToken,
|
||||
"client_id": oauth2ClientID,
|
||||
"scope": resolveRefreshScopes(credentials.Scopes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, E.Cause(err, "marshal request")
|
||||
@@ -194,10 +247,12 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau
|
||||
return nil, 0, E.New("refresh failed: ", response.Status, " ", string(body))
|
||||
}
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6 response (line 172769-172772)
|
||||
var tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AccessToken string `json:"access_token"` // ref: cli.js line 172770 z
|
||||
RefreshToken string `json:"refresh_token"` // ref: cli.js line 172770 w (defaults to input)
|
||||
ExpiresIn int `json:"expires_in"` // ref: cli.js line 172770 O
|
||||
Scope string `json:"scope"` // ref: cli.js line 172772 uB6(Y.scope)
|
||||
}
|
||||
err = json.NewDecoder(response.Body).Decode(&tokenResponse)
|
||||
if err != nil {
|
||||
@@ -210,6 +265,11 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau
|
||||
newCredentials.RefreshToken = tokenResponse.RefreshToken
|
||||
}
|
||||
newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000
|
||||
// ref: cli.js uB6 (line 172696-172697): A?.split(" ").filter(Boolean)
|
||||
// strings.Fields matches .filter(Boolean): splits on whitespace runs, removes empty strings
|
||||
if tokenResponse.Scope != "" {
|
||||
newCredentials.Scopes = strings.Fields(tokenResponse.Scope)
|
||||
}
|
||||
|
||||
return &newCredentials, 0, nil
|
||||
}
|
||||
|
||||
@@ -372,6 +372,43 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ref (@anthropic-ai/claude-code @2.1.81): cli.js NA9 (line 179488-179494) — 401 recovery
|
||||
// ref: cli.js CR1 (line 314268-314273) — 403 "OAuth token has been revoked" recovery
|
||||
if !selectedCredential.isExternal() && bodyBytes != nil &&
|
||||
(response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden) {
|
||||
shouldRetry := response.StatusCode == http.StatusUnauthorized
|
||||
if response.StatusCode == http.StatusForbidden {
|
||||
peekBody, _ := io.ReadAll(response.Body)
|
||||
shouldRetry = strings.Contains(string(peekBody), "OAuth token has been revoked")
|
||||
if !shouldRetry {
|
||||
response.Body.Close()
|
||||
s.logger.ErrorContext(ctx, "upstream error from ", selectedCredential.tagName(), ": status ", response.StatusCode, " ", string(peekBody))
|
||||
writeJSONError(w, r, http.StatusInternalServerError, "api_error",
|
||||
"proxy request (status "+strconv.Itoa(response.StatusCode)+"): "+string(peekBody))
|
||||
return
|
||||
}
|
||||
}
|
||||
if shouldRetry {
|
||||
response.Body.Close()
|
||||
s.logger.WarnContext(ctx, "upstream auth failure from ", selectedCredential.tagName(), ", reloading credentials and retrying")
|
||||
if defaultCred, ok := selectedCredential.(*defaultCredential); ok {
|
||||
_ = defaultCred.reloadCredentials(true)
|
||||
}
|
||||
retryRequest, buildErr := selectedCredential.buildProxyRequest(requestContext, r, bodyBytes, s.httpHeaders)
|
||||
if buildErr != nil {
|
||||
writeJSONError(w, r, http.StatusBadGateway, "api_error", E.Cause(buildErr, "rebuild request after auth recovery").Error())
|
||||
return
|
||||
}
|
||||
retryResponse, retryErr := selectedCredential.httpClient().Do(retryRequest)
|
||||
if retryErr != nil {
|
||||
writeJSONError(w, r, http.StatusBadGateway, "api_error", E.Cause(retryErr, "retry request after auth recovery").Error())
|
||||
return
|
||||
}
|
||||
response = retryResponse
|
||||
defer retryResponse.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusTooManyRequests {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
s.logger.ErrorContext(ctx, "upstream error from ", selectedCredential.tagName(), ": status ", response.StatusCode, " ", string(body))
|
||||
|
||||
Reference in New Issue
Block a user