@@ -16,8 +16,9 @@ import (
1616// Default refresh configuration
1717const (
1818 // DefaultRefreshThreshold is the percentage of token lifetime at which proactive refresh triggers.
19- // 0.8 means refresh at 80% of lifetime (e.g., 30s token → refresh at 24s).
20- DefaultRefreshThreshold = 0.8
19+ // Used in hybrid calculation: refresh at the EARLIER of (threshold * lifetime) or (expiry - MinRefreshBuffer).
20+ // 0.75 means refresh at 75% of lifetime for long-lived tokens.
21+ DefaultRefreshThreshold = 0.75
2122
2223 // DefaultMaxRetries is the maximum number of refresh attempts before giving up.
2324 // Set to 0 for unlimited retries until token expiration (FR-009).
@@ -26,6 +27,11 @@ const (
2627 // MinRefreshInterval prevents too-frequent refresh attempts.
2728 MinRefreshInterval = 5 * time .Second
2829
30+ // MinRefreshBuffer is the minimum time before expiration to schedule a refresh.
31+ // This ensures adequate time for retries even with short-lived tokens.
32+ // Industry best practice: Google and Microsoft recommend 5 minutes.
33+ MinRefreshBuffer = 5 * time .Minute
34+
2935 // RetryBackoffBase is the base duration for exponential backoff on retry.
3036 // Per FR-008: minimum 10 seconds between refresh attempts per server.
3137 RetryBackoffBase = 10 * time .Second
@@ -45,7 +51,7 @@ type RefreshState int
4551const (
4652 // RefreshStateIdle means no refresh is pending or in progress.
4753 RefreshStateIdle RefreshState = iota
48- // RefreshStateScheduled means a proactive refresh is scheduled at 80 % lifetime.
54+ // RefreshStateScheduled means a proactive refresh is scheduled (hybrid: 75 % lifetime or 5min buffer) .
4955 RefreshStateScheduled
5056 // RefreshStateRetrying means refresh failed and is retrying with exponential backoff.
5157 RefreshStateRetrying
@@ -73,7 +79,7 @@ func (s RefreshState) String() string {
7379type RefreshSchedule struct {
7480 ServerName string // Unique server identifier
7581 ExpiresAt time.Time // When the current token expires
76- ScheduledRefresh time.Time // When proactive refresh is scheduled (80% of lifetime )
82+ ScheduledRefresh time.Time // When proactive refresh is scheduled (hybrid strategy )
7783 RetryCount int // Number of refresh retry attempts
7884 LastError string // Last refresh error message
7985 Timer * time.Timer // Background timer for scheduled refresh
@@ -184,7 +190,7 @@ func (m *RefreshManager) SetMetricsRecorder(recorder RefreshMetricsRecorder) {
184190}
185191
186192// Start initializes the refresh manager and loads existing tokens.
187- // For non-expired tokens, it schedules proactive refresh at 80% lifetime .
193+ // For non-expired tokens, it schedules proactive refresh using hybrid strategy .
188194// For expired tokens with valid refresh tokens, it attempts immediate refresh.
189195func (m * RefreshManager ) Start (ctx context.Context ) error {
190196 m .mu .Lock ()
@@ -239,7 +245,7 @@ func (m *RefreshManager) Start(ctx context.Context) error {
239245 zap .Duration ("time_until_expiry" , token .ExpiresAt .Sub (now )))
240246
241247 if token .ExpiresAt .After (now ) {
242- // Token not expired - schedule proactive refresh at 80% lifetime
248+ // Token not expired - schedule proactive refresh using hybrid strategy
243249 m .logger .Debug ("Scheduling proactive refresh for non-expired token" ,
244250 zap .String ("server" , serverName ),
245251 zap .Time ("expires_at" , token .ExpiresAt ))
@@ -502,10 +508,15 @@ func (m *RefreshManager) GetRefreshState(serverName string) *RefreshStateInfo {
502508
503509// scheduleRefreshLocked schedules a proactive refresh for a token.
504510// Must be called with m.mu held.
511+ //
512+ // Uses a hybrid refresh strategy (industry best practice):
513+ // - Refresh at the EARLIER of: (threshold * lifetime) OR (expiry - MinRefreshBuffer)
514+ // - This ensures short-lived tokens get adequate buffer time for retries
515+ // - Long-lived tokens refresh at 75% of lifetime (e.g., 1-hour token → 45 min)
516+ // - Short-lived tokens get at least 5 minutes buffer (e.g., 10-min token → 5 min)
505517func (m * RefreshManager ) scheduleRefreshLocked (serverName string , expiresAt time.Time ) {
506518 now := time .Now ()
507519
508- // Calculate when to refresh (at threshold % of lifetime)
509520 lifetime := expiresAt .Sub (now )
510521 if lifetime <= 0 {
511522 m .logger .Debug ("Token already expired, skipping schedule" ,
@@ -514,17 +525,34 @@ func (m *RefreshManager) scheduleRefreshLocked(serverName string, expiresAt time
514525 return
515526 }
516527
517- // Calculate refresh time at threshold of remaining lifetime
518- refreshDelay := time .Duration (float64 (lifetime ) * m .threshold )
528+ // Hybrid refresh calculation:
529+ // 1. Percentage-based: refresh at threshold% of lifetime (default 75%)
530+ // 2. Buffer-based: refresh at (expiry - MinRefreshBuffer) for minimum safety margin
531+ // Use the EARLIER of the two to ensure adequate time for retries
532+ percentageDelay := time .Duration (float64 (lifetime ) * m .threshold )
533+ bufferDelay := lifetime - MinRefreshBuffer
534+
535+ // Choose the earlier refresh time (smaller delay)
536+ var refreshDelay time.Duration
537+ var strategy string
538+ if bufferDelay > 0 && bufferDelay < percentageDelay {
539+ // Buffer-based is earlier - use it for short-lived tokens
540+ refreshDelay = bufferDelay
541+ strategy = "buffer-based"
542+ } else {
543+ // Percentage-based is earlier or buffer would be negative
544+ refreshDelay = percentageDelay
545+ strategy = "percentage-based"
546+ }
519547
520- // Ensure minimum interval
548+ // Ensure minimum interval (prevents hammering on very short tokens)
521549 if refreshDelay < MinRefreshInterval {
522550 refreshDelay = MinRefreshInterval
523551 }
524552
525553 refreshAt := now .Add (refreshDelay )
526554
527- // If refresh would be after expiration, schedule for just before expiration
555+ // Final safety check: ensure we're not scheduling after expiration
528556 if refreshAt .After (expiresAt .Add (- MinRefreshInterval )) {
529557 refreshAt = expiresAt .Add (- MinRefreshInterval )
530558 refreshDelay = refreshAt .Sub (now )
@@ -534,6 +562,7 @@ func (m *RefreshManager) scheduleRefreshLocked(serverName string, expiresAt time
534562 zap .Time ("expires_at" , expiresAt ))
535563 return
536564 }
565+ strategy = "minimum-interval"
537566 }
538567
539568 // Create or update schedule
@@ -558,6 +587,8 @@ func (m *RefreshManager) scheduleRefreshLocked(serverName string, expiresAt time
558587 zap .Time ("expires_at" , expiresAt ),
559588 zap .Time ("refresh_at" , refreshAt ),
560589 zap .Duration ("delay" , refreshDelay ),
590+ zap .Duration ("buffer" , expiresAt .Sub (refreshAt )),
591+ zap .String ("strategy" , strategy ),
561592 zap .Float64 ("threshold" , m .threshold ))
562593}
563594
0 commit comments