Resilient Authentication: Implementing Robust Token Retry Logic
Introduction
In security-critical systems like smart lock management, authentication failures can be catastrophic. A single network hiccup or temporary server overload shouldn't lock users out of their properties. This article explores how to implement resilient token refresh mechanisms that gracefully handle transient failures while maintaining security guarantees.
The Challenge of Token Management in Critical Systems
Smart lock systems require continuous authentication validity. Unlike web applications where users can simply re-login, physical access systems must handle authentication seamlessly. Token expiration at the wrong moment could mean a guest can't enter their room or property managers lose access during emergencies.
Implementing Exponential Backoff with Circuit Breaking
A robust token refresh system combines multiple resilience patterns:
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
class TokenManager {
private config: RetryConfig = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2
};
async refreshTokenWithRetry(): Promise<string> {
let lastError: Error;
for (let attempt = 0; attempt < this.config.maxAttempts; attempt++) {
try {
const token = await this.performTokenRefresh();
await this.validateTokenClaims(token);
return token;
} catch (error) {
lastError = error;
if (!this.isRetryableError(error) || attempt === this.config.maxAttempts - 1) {
throw error;
}
const delay = this.calculateBackoffDelay(attempt);
await this.sleep(delay);
}
}
throw lastError;
}
private isRetryableError(error: Error): boolean {
// Only retry on network/server errors, not auth failures
return error instanceof NetworkError ||
error instanceof ServerError;
}
private calculateBackoffDelay(attempt: number): number {
const exponentialDelay = this.config.baseDelayMs *
Math.pow(this.config.backoffMultiplier, attempt);
// Add jitter to prevent thundering herd
const jitter = Math.random() * 0.1 * exponentialDelay;
return Math.min(
exponentialDelay + jitter,
this.config.maxDelayMs
);
}
}State Preservation During Authentication Failures
Critical operations like check-in processes must maintain state consistency even when authentication temporarily fails:
interface CheckInState {
propertyId: string;
guestId: string;
checkInTime: Date;
lockCodes: string[];
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}
class ResilientCheckInManager {
private stateStorage = new SecureStateStorage();
async performCheckIn(request: CheckInRequest): Promise<void> {
const checkInId = this.generateCheckInId();
let state: CheckInState = {
propertyId: request.propertyId,
guestId: request.guestId,
checkInTime: new Date(),
lockCodes: [],
status: 'pending'
};
try {
await this.stateStorage.save(checkInId, state);
state.status = 'in_progress';
await this.stateStorage.update(checkInId, state);
// This might fail due to token issues
const lockCodes = await this.generateLockCodes(request);
state.lockCodes = lockCodes;
state.status = 'completed';
await this.stateStorage.update(checkInId, state);
} catch (error) {
if (error instanceof AuthenticationError) {
// Preserve state for retry, don't fail the entire operation
await this.scheduleRetry(checkInId, state);
throw new TransientCheckInError('Check-in will be retried automatically');
}
state.status = 'failed';
await this.stateStorage.update(checkInId, state);
throw error;
}
}
private async scheduleRetry(checkInId: string, state: CheckInState): Promise<void> {
const retryJob = {
id: checkInId,
type: 'check_in_retry',
state,
scheduledAt: new Date(Date.now() + 30000) // 30 second delay
};
await this.jobQueue.schedule(retryJob);
}
}Preventing Duplicate Operations with Idempotency
Transient failures can lead to duplicate operations. Idempotency keys ensure operations are safe to retry:
class IdempotentOperationManager {
private operationCache = new Map<string, OperationResult>();
async executeWithIdempotency<T>(
key: string,
operation: () => Promise<T>,
ttlMs: number = 300000 // 5 minutes
): Promise<T> {
// Check if operation already completed
const cached = this.operationCache.get(key);
if (cached && !this.isExpired(cached, ttlMs)) {
return cached.result as T;
}
try {
const result = await operation();
// Cache successful result
this.operationCache.set(key, {
result,
timestamp: Date.now(),
status: 'success'
});
return result;
} catch (error) {
// Don't cache failures for auth errors - they should be retried
if (!(error instanceof AuthenticationError)) {
this.operationCache.set(key, {
result: error,
timestamp: Date.now(),
status: 'error'
});
}
throw error;
}
}
private isExpired(cached: OperationResult, ttlMs: number): boolean {
return Date.now() - cached.timestamp > ttlMs;
}
}Comprehensive Error Classification
Not all errors should be handled the same way. A robust system distinguishes between error types:
enum ErrorCategory {
AUTHENTICATION = 'auth',
AUTHORIZATION = 'authz',
NETWORK = 'network',
VALIDATION = 'validation',
BUSINESS_LOGIC = 'business',
SYSTEM = 'system'
}
class ErrorClassifier {
static classify(error: Error): ErrorCategory {
if (error.message.includes('401') || error.message.includes('invalid_token')) {
return ErrorCategory.AUTHENTICATION;
}
if (error.message.includes('403') || error.message.includes('insufficient_permissions')) {
return ErrorCategory.AUTHORIZATION;
}
if (error instanceof TypeError && error.message.includes('fetch')) {
return ErrorCategory.NETWORK;
}
return ErrorCategory.SYSTEM;
}
static isRetryable(category: ErrorCategory): boolean {
return [ErrorCategory.NETWORK, ErrorCategory.SYSTEM].includes(category);
}
static getRetryStrategy(category: ErrorCategory): RetryStrategy {
switch (category) {
case ErrorCategory.NETWORK:
return { maxAttempts: 3, baseDelay: 1000 };
case ErrorCategory.AUTHENTICATION:
return { maxAttempts: 1, baseDelay: 0 }; // Don't retry auth failures
default:
return { maxAttempts: 2, baseDelay: 500 };
}
}
}Monitoring and Alerting
Production resilience requires observability:
class AuthenticationMetrics {
async recordTokenRefresh(success: boolean, attemptCount: number, duration: number): Promise<void> {
await this.metrics.increment('token_refresh_attempts', {
success: success.toString(),
attempt_count: attemptCount.toString()
});
await this.metrics.histogram('token_refresh_duration_ms', duration);
// Alert on high failure rates
if (!success && attemptCount >= 3) {
await this.alerting.sendAlert({
severity: 'high',
message: 'Token refresh failed after maximum retries',
context: { attemptCount, duration }
});
}
}
}Summary
Building resilient authentication for security-critical systems requires a multi-layered approach:
- Smart Retry Logic: Implement exponential backoff with jitter, but only retry appropriate error types
- State Preservation: Maintain operation state across authentication failures to enable seamless recovery
- Idempotency: Prevent duplicate operations with unique keys and result caching
- Error Classification: Distinguish between different failure modes and handle each appropriately
- Observability: Monitor failure patterns and alert on concerning trends
These patterns ensure that temporary authentication hiccups don't compromise user experience or system security. In smart lock systems where access is critical, such resilience can mean the difference between a minor inconvenience and a major operational failure.