Skip to content

Commit a5f0a5a

Browse files
feat(oauth): implement token refresh reliability with exponential backoff (smart-mcp-proxy#23)
Implements the OAuth token refresh reliability feature per Spec 023. Key changes: - Add RefreshState type for tracking refresh status (idle/scheduled/retrying/failed) - Implement exponential backoff for refresh failures (10s → 20s → 40s → ... → 5m cap) - Add rate limiting (min 10s between attempts per server) - Detect expired tokens at startup and attempt immediate refresh - Continue retries until token expiration (unlimited retries per FR-009) - Classify errors as permanent (invalid_grant) vs retryable (network) - Extend health calculator to show refresh state in health status - Add OAuth refresh metrics (mcpproxy_oauth_refresh_total, duration) - Fix misleading logging: rename LogTokenRefreshSuccess to LogClientConnectionSuccess User Stories Implemented: - US1: Survive laptop sleep/wake - auto-reconnect using stored refresh tokens - US2: Proactive token refresh at 80% lifetime with exponential backoff - US3: Clear refresh failure feedback in health status Spec: smart-mcp-proxy#253 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0ae4afd commit a5f0a5a

7 files changed

Lines changed: 647 additions & 106 deletions

File tree

internal/health/calculator.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ import (
88
"mcpproxy-go/internal/contracts"
99
)
1010

11+
// RefreshState represents the current state of token refresh for health reporting.
12+
// Mirrors oauth.RefreshState for decoupling.
13+
type RefreshState int
14+
15+
const (
16+
// RefreshStateIdle means no refresh is pending or in progress.
17+
RefreshStateIdle RefreshState = iota
18+
// RefreshStateScheduled means a proactive refresh is scheduled at 80% lifetime.
19+
RefreshStateScheduled
20+
// RefreshStateRetrying means refresh failed and is retrying with exponential backoff.
21+
RefreshStateRetrying
22+
// RefreshStateFailed means refresh permanently failed (e.g., invalid_grant).
23+
RefreshStateFailed
24+
)
25+
1126
// HealthCalculatorInput contains all fields needed to calculate health status.
1227
// This struct normalizes data from different sources (StateView, storage, config).
1328
type HealthCalculatorInput struct {
@@ -36,6 +51,12 @@ type HealthCalculatorInput struct {
3651

3752
// Tool info
3853
ToolCount int
54+
55+
// Refresh state (for health status integration - Spec 023)
56+
RefreshState RefreshState // Current refresh state from RefreshManager
57+
RefreshRetryCount int // Number of retry attempts
58+
RefreshLastError string // Human-readable error message
59+
RefreshNextAttempt *time.Time // When next retry will occur
3960
}
4061

4162
// HealthCalculatorConfig contains configurable thresholds for health calculation.
@@ -219,7 +240,35 @@ func CalculateHealth(input HealthCalculatorInput, cfg *HealthCalculatorConfig) *
219240
}
220241
}
221242

222-
// 6. Healthy state - connected with valid authentication (if required)
243+
// 6. Refresh state checks (Spec 023)
244+
// Check if refresh is in a degraded or failed state
245+
switch input.RefreshState {
246+
case RefreshStateRetrying:
247+
// Refresh failed but retrying - degraded status
248+
detail := formatRefreshRetryDetail(input.RefreshRetryCount, input.RefreshNextAttempt, input.RefreshLastError)
249+
return &contracts.HealthStatus{
250+
Level: LevelDegraded,
251+
AdminState: StateEnabled,
252+
Summary: "Token refresh pending",
253+
Detail: detail,
254+
Action: ActionViewLogs,
255+
}
256+
case RefreshStateFailed:
257+
// Refresh permanently failed - unhealthy status
258+
detail := "Re-authentication required"
259+
if input.RefreshLastError != "" {
260+
detail = fmt.Sprintf("Re-authentication required: %s", input.RefreshLastError)
261+
}
262+
return &contracts.HealthStatus{
263+
Level: LevelUnhealthy,
264+
AdminState: StateEnabled,
265+
Summary: "Refresh token expired",
266+
Detail: detail,
267+
Action: ActionLogin,
268+
}
269+
}
270+
271+
// 7. Healthy state - connected with valid authentication (if required)
223272
return &contracts.HealthStatus{
224273
Level: LevelHealthy,
225274
AdminState: StateEnabled,
@@ -302,6 +351,30 @@ func formatExpiringTokenSummary(timeUntilExpiry time.Duration) string {
302351
return fmt.Sprintf("Token expiring in %dh", hours)
303352
}
304353

354+
// formatRefreshRetryDetail formats the detail message for a refresh retry state.
355+
func formatRefreshRetryDetail(retryCount int, nextAttempt *time.Time, lastError string) string {
356+
var detail string
357+
358+
// Start with retry count and next attempt time
359+
if nextAttempt != nil && !nextAttempt.IsZero() {
360+
detail = fmt.Sprintf("Refresh retry %d scheduled for %s", retryCount, nextAttempt.Format(time.RFC3339))
361+
} else {
362+
detail = fmt.Sprintf("Refresh retry %d pending", retryCount)
363+
}
364+
365+
// Add last error if available
366+
if lastError != "" {
367+
// Truncate error if too long
368+
errorMsg := lastError
369+
if len(errorMsg) > 100 {
370+
errorMsg = errorMsg[:97] + "..."
371+
}
372+
detail = fmt.Sprintf("%s: %s", detail, errorMsg)
373+
}
374+
375+
return detail
376+
}
377+
305378
// containsIgnoreCase checks if s contains substr, ignoring case.
306379
func containsIgnoreCase(s, substr string) bool {
307380
return len(s) >= len(substr) &&

internal/oauth/logging.go

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,29 +159,78 @@ func LogTokenMetadata(logger *zap.Logger, metadata TokenMetadata) {
159159
)
160160
}
161161

162-
// LogTokenRefreshAttempt logs a token refresh attempt.
163-
func LogTokenRefreshAttempt(logger *zap.Logger, attempt int, maxAttempts int) {
164-
logger.Info("Attempting OAuth token refresh",
162+
// LogClientConnectionAttempt logs a client connection attempt (not an actual token refresh).
163+
// Note: This is called when retrying client.Start(), which may trigger automatic
164+
// token refresh internally by mcp-go, but we cannot observe whether refresh actually occurred.
165+
func LogClientConnectionAttempt(logger *zap.Logger, attempt int, maxAttempts int) {
166+
logger.Info("OAuth client connection attempt",
165167
zap.Int("attempt", attempt),
166168
zap.Int("max_attempts", maxAttempts),
167169
)
168170
}
169171

170-
// LogTokenRefreshSuccess logs a successful token refresh.
171-
func LogTokenRefreshSuccess(logger *zap.Logger, duration time.Duration) {
172-
logger.Info("OAuth token refresh successful",
172+
// LogClientConnectionSuccess logs a successful client connection.
173+
// Note: This does NOT mean a token refresh occurred - it means the client connected.
174+
// The mcp-go library may have used a cached token or performed automatic refresh internally.
175+
func LogClientConnectionSuccess(logger *zap.Logger, duration time.Duration) {
176+
logger.Info("OAuth client connection successful",
173177
zap.Duration("duration", duration),
174178
)
175179
}
176180

177-
// LogTokenRefreshFailure logs a failed token refresh attempt.
178-
func LogTokenRefreshFailure(logger *zap.Logger, attempt int, err error) {
179-
logger.Warn("OAuth token refresh failed",
181+
// LogClientConnectionFailure logs a failed client connection attempt.
182+
func LogClientConnectionFailure(logger *zap.Logger, attempt int, err error) {
183+
logger.Warn("OAuth client connection failed",
180184
zap.Int("attempt", attempt),
181185
zap.Error(err),
182186
)
183187
}
184188

189+
// Deprecated: Use LogClientConnectionAttempt instead.
190+
// LogTokenRefreshAttempt is kept for backward compatibility but is misleading.
191+
func LogTokenRefreshAttempt(logger *zap.Logger, attempt int, maxAttempts int) {
192+
LogClientConnectionAttempt(logger, attempt, maxAttempts)
193+
}
194+
195+
// Deprecated: Use LogClientConnectionSuccess instead.
196+
// LogTokenRefreshSuccess is kept for backward compatibility but is misleading.
197+
// This is called when client.Start() succeeds, not when a token refresh occurs.
198+
func LogTokenRefreshSuccess(logger *zap.Logger, duration time.Duration) {
199+
LogClientConnectionSuccess(logger, duration)
200+
}
201+
202+
// Deprecated: Use LogClientConnectionFailure instead.
203+
// LogTokenRefreshFailure is kept for backward compatibility but is misleading.
204+
func LogTokenRefreshFailure(logger *zap.Logger, attempt int, err error) {
205+
LogClientConnectionFailure(logger, attempt, err)
206+
}
207+
208+
// LogActualTokenRefreshAttempt logs an actual proactive token refresh attempt.
209+
// This is called by RefreshManager when it initiates a token refresh operation.
210+
func LogActualTokenRefreshAttempt(logger *zap.Logger, serverName string, tokenAge time.Duration) {
211+
logger.Info("OAuth token refresh attempt",
212+
zap.String("server", serverName),
213+
zap.Duration("token_age", tokenAge),
214+
)
215+
}
216+
217+
// LogActualTokenRefreshResult logs the result of an actual token refresh operation.
218+
// This is called by RefreshManager after a refresh attempt completes.
219+
func LogActualTokenRefreshResult(logger *zap.Logger, serverName string, success bool, duration time.Duration, err error) {
220+
if success {
221+
logger.Info("OAuth token refresh succeeded",
222+
zap.String("server", serverName),
223+
zap.Duration("duration", duration),
224+
)
225+
} else {
226+
logger.Warn("OAuth token refresh failed",
227+
zap.String("server", serverName),
228+
zap.Duration("duration", duration),
229+
zap.Error(err),
230+
)
231+
}
232+
}
233+
185234
// LogOAuthFlowStart logs the start of an OAuth flow.
186235
func LogOAuthFlowStart(logger *zap.Logger, serverName string, correlationID string) {
187236
logger.Info("Starting OAuth flow",

0 commit comments

Comments
 (0)