github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/cache.go (about) 1 package auth 2 3 import ( 4 "context" 5 "net/http" 6 "sync" 7 "time" 8 ) 9 10 // advisoryRefreshBackoff caps how often a failed background refresh will 11 // re-run in the advisory window. Without this, a token endpoint that is 12 // down during the advisory window would be re-hit at request rate until 13 // the mandatory threshold is crossed. 14 const advisoryRefreshBackoff = 5 * time.Second 15 16 // TokenCache wraps a [TokenProvider] with two-tier proactive refresh and 17 // thread-safe caching. 18 // 19 // Refresh tiers: 20 // - No cached token: fetch synchronously 21 // - ExpiresAt == nil: cache forever 22 // - More than [AdvisoryRefreshThreshold] remaining: return cached 23 // - Between mandatory and advisory thresholds: try background refresh; 24 // on failure, return the stale token 25 // - Less than [MandatoryRefreshThreshold] remaining or expired: refresh 26 // synchronously; on failure, return the error 27 // 28 // A cached token is scoped to the baseURL it was minted for: if a caller 29 // passes a different baseURL to a subsequent [TokenCache.Token] call, the 30 // cached token is discarded so a token issued by one environment can never 31 // be served for a request going to another. 32 type TokenCache struct { 33 provider TokenProvider 34 httpHandler func(*http.Request) (*http.Response, error) 35 36 mu sync.Mutex 37 cached *AccessToken 38 cachedBaseURL string 39 refresh *refreshState 40 lastAdvisoryError time.Time 41 } 42 43 type refreshState struct { 44 done chan struct{} 45 token *AccessToken 46 err error 47 } 48 49 // NewTokenCache returns a [TokenCache] that delegates to the given provider. 50 // The handler is passed through to the provider on every fetch; the base URL 51 // to exchange at is supplied per call to [TokenCache.Token]. 52 func NewTokenCache(provider TokenProvider, handler func(*http.Request) (*http.Response, error)) *TokenCache { 53 return &TokenCache{provider: provider, httpHandler: handler} 54 } 55 56 // Token returns a valid access token, fetching or refreshing via the provider 57 // if necessary. baseURL is forwarded to the provider for token-exchange / 58 // refresh HTTP calls; a fresh cached token bypasses the provider entirely. 59 func (cache *TokenCache) Token(ctx context.Context, baseURL string) (string, error) { 60 if ok, token := cache.check(ctx, baseURL); ok { 61 return token, nil 62 } 63 return cache.fetch(ctx, baseURL) 64 } 65 66 // ForceToken is like [TokenCache.Token] but signals token providers that 67 // maintain their own caches (e.g., file-backed credential loaders) to bypass 68 // them and re-exchange the underlying credential. Call this after a 401 has 69 // invalidated the in-memory cache, so a stale on-disk token can't re-surface 70 // for the retry. 71 func (cache *TokenCache) ForceToken(ctx context.Context, baseURL string) (string, error) { 72 return cache.Token(withForceRefresh(ctx), baseURL) 73 } 74 75 // Invalidate drops the in-memory cache so the next call to [TokenCache.Token] 76 // re-enters the provider. Pair with [TokenCache.ForceToken] on the retry path 77 // to also bypass provider-side caches. Returns true if there was a cached 78 // token to drop. 79 func (cache *TokenCache) Invalidate() bool { 80 cache.mu.Lock() 81 defer cache.mu.Unlock() 82 if cache.cached == nil { 83 return false 84 } 85 cache.cached = nil 86 cache.cachedBaseURL = "" 87 return true 88 } 89 90 // check inspects the cached token under the lock and returns whether 91 // the caller can use it directly or must fetch a new one. 92 func (cache *TokenCache) check(ctx context.Context, baseURL string) (bool, string) { 93 cache.mu.Lock() 94 defer cache.mu.Unlock() 95 96 if cache.cached == nil { 97 return false, "" 98 } 99 100 // Scope cached tokens to the baseURL they were minted for. 101 if cache.cachedBaseURL != baseURL { 102 cache.cached = nil 103 cache.cachedBaseURL = "" 104 return false, "" 105 } 106 107 if cache.cached.ExpiresAt == nil { 108 // No expiry. Cache forever. 109 return true, cache.cached.Token 110 } 111 112 remaining := time.Until(*cache.cached.ExpiresAt) 113 114 if remaining > AdvisoryRefreshThreshold { 115 // Fresh enough. Use the cached token. 116 return true, cache.cached.Token 117 } 118 119 if remaining > MandatoryRefreshThreshold { 120 // Advisory window. Kick off background refresh (subject to backoff 121 // on repeated failure), return stale. 122 if time.Since(cache.lastAdvisoryError) >= advisoryRefreshBackoff { 123 cache.startBackgroundRefresh(ctx, baseURL) 124 } 125 return true, cache.cached.Token 126 } 127 128 // Mandatory window or expired. 129 return false, "" 130 } 131 132 // fetch performs a synchronous token fetch. If another goroutine is already 133 // fetching, it waits for that result instead of starting a duplicate. Callers 134 // in the mandatory window (the only callers that reach fetch) never receive 135 // a stale token: if the refresh fails, the error propagates. 136 func (cache *TokenCache) fetch(ctx context.Context, baseURL string) (string, error) { 137 isFetcher, refresh := cache.getOrStartRefresh() 138 if !isFetcher { 139 return awaitRefresh(ctx, refresh) 140 } 141 142 token, err := cache.provider(ctx, baseURL, cache.httpHandler) 143 cache.completeRefresh(refresh, baseURL, token, err, false) 144 if err != nil { 145 return "", err 146 } 147 return token.Token, nil 148 } 149 150 // getOrStartRefresh returns the in-flight refresh if one exists, or starts a 151 // new one. The bool indicates whether the caller is the fetcher. 152 func (cache *TokenCache) getOrStartRefresh() (bool, *refreshState) { 153 cache.mu.Lock() 154 defer cache.mu.Unlock() 155 156 if refresh := cache.refresh; refresh != nil { 157 return false, refresh 158 } 159 160 refresh := &refreshState{done: make(chan struct{})} 161 cache.refresh = refresh 162 return true, refresh 163 } 164 165 // awaitRefresh blocks until the in-flight refresh completes and returns its 166 // result. Piggy-backing callers never receive a stale token on failure; they 167 // are by construction in the mandatory window, where stale is unacceptable. 168 func awaitRefresh(ctx context.Context, refresh *refreshState) (string, error) { 169 select { 170 case <-refresh.done: 171 case <-ctx.Done(): 172 return "", ctx.Err() 173 } 174 if refresh.err != nil { 175 return "", refresh.err 176 } 177 return refresh.token.Token, nil 178 } 179 180 // completeRefresh records the result of a fetch, updates the cache, and 181 // signals all waiters. advisory indicates whether this refresh ran in the 182 // advisory (background) window — on failure, advisory refreshes arm the 183 // backoff instead of propagating the error. 184 func (cache *TokenCache) completeRefresh(refresh *refreshState, baseURL string, token *AccessToken, err error, advisory bool) { 185 cache.mu.Lock() 186 defer cache.mu.Unlock() 187 refresh.token = token 188 refresh.err = err 189 if err == nil { 190 cache.cached = token 191 cache.cachedBaseURL = baseURL 192 cache.lastAdvisoryError = time.Time{} 193 } else if advisory { 194 cache.lastAdvisoryError = time.Now() 195 } 196 cache.refresh = nil 197 close(refresh.done) 198 } 199 200 // startBackgroundRefresh kicks off an advisory background refresh if one isn't 201 // already in-flight. Must be called with mu held. 202 func (cache *TokenCache) startBackgroundRefresh(ctx context.Context, baseURL string) { 203 if cache.refresh != nil { 204 return 205 } 206 207 refresh := &refreshState{done: make(chan struct{})} 208 cache.refresh = refresh 209 210 go func() { 211 token, err := cache.provider(context.WithoutCancel(ctx), baseURL, cache.httpHandler) 212 cache.completeRefresh(refresh, baseURL, token, err, true) 213 }() 214 } 215 216 type forceRefreshKey struct{} 217 218 func withForceRefresh(ctx context.Context) context.Context { 219 return context.WithValue(ctx, forceRefreshKey{}, true) 220 } 221 222 // isForceRefresh reports whether ctx carries a force-refresh signal. Token 223 // providers that maintain their own caches should skip them when true. 224 func isForceRefresh(ctx context.Context) bool { 225 v, _ := ctx.Value(forceRefreshKey{}).(bool) 226 return v 227 }