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  }