@felixgeelhaar/govee-api-client
Version:
Enterprise-grade TypeScript client library for the Govee Developer REST API
373 lines • 13 kB
JavaScript
import { RateLimitError, NetworkError, GoveeApiError, InvalidApiKeyError, } from '../../errors';
/**
* Circuit breaker state management
*/
class CircuitBreaker {
constructor(config, logger) {
this.config = config;
this.logger = logger;
this.state = 'closed';
this.failureCount = 0;
this.lastFailureTime = 0;
this.successCount = 0;
}
canExecute() {
const now = Date.now();
switch (this.state) {
case 'closed':
return true;
case 'open':
if (now - this.lastFailureTime >= this.config.recoveryTimeoutMs) {
this.logger?.info('Circuit breaker transitioning to half-open state');
this.state = 'half-open';
this.successCount = 0;
return true;
}
return false;
case 'half-open':
return true;
default:
return false;
}
}
recordSuccess() {
this.failureCount = 0;
if (this.state === 'half-open') {
this.successCount++;
if (this.successCount >= this.config.halfOpenSuccessThreshold) {
this.logger?.info('Circuit breaker transitioning to closed state');
this.state = 'closed';
}
}
}
recordFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === 'closed' && this.failureCount >= this.config.failureThreshold) {
this.logger?.warn('Circuit breaker transitioning to open state');
this.state = 'open';
}
else if (this.state === 'half-open') {
this.logger?.warn('Circuit breaker returning to open state');
this.state = 'open';
this.successCount = 0;
}
}
getState() {
return this.state;
}
reset() {
this.state = 'closed';
this.failureCount = 0;
this.lastFailureTime = 0;
this.successCount = 0;
this.logger?.info('Circuit breaker reset to closed state');
}
}
/**
* Enterprise-grade retry policy with exponential backoff, jitter, and circuit breaker
*/
export class RetryPolicy {
constructor(config) {
this.config = config;
this.logger = config.logger;
this.validateConfig();
if (config.circuitBreaker?.enabled) {
this.circuitBreaker = new CircuitBreaker(config.circuitBreaker, this.logger);
}
this.metrics = {
totalAttempts: 0,
successfulRetries: 0,
failedRetries: 0,
totalRetryTimeMs: 0,
averageRetryDelayMs: 0,
circuitBreakerState: this.circuitBreaker?.getState() || 'closed',
};
}
validateConfig() {
const { backoff, condition, jitter } = this.config;
if (backoff.initialDelayMs <= 0 || backoff.maxDelayMs <= 0) {
throw new Error('Retry delays must be positive');
}
if (backoff.initialDelayMs > backoff.maxDelayMs) {
throw new Error('Initial delay cannot exceed maximum delay');
}
if (condition.maxAttempts <= 0 || condition.maxTotalTimeMs <= 0) {
throw new Error('Retry limits must be positive');
}
if (jitter.factor !== undefined && (jitter.factor < 0 || jitter.factor > 1)) {
throw new Error('Jitter factor must be between 0 and 1');
}
if (backoff.type === 'exponential' && (backoff.multiplier || 2) <= 1) {
throw new Error('Exponential backoff multiplier must be greater than 1');
}
}
/**
* Determines if an error should trigger a retry attempt
*/
shouldRetry(error, attempt, elapsedTimeMs) {
const { condition } = this.config;
// Check circuit breaker
if (this.circuitBreaker && !this.circuitBreaker.canExecute()) {
this.logger?.debug('Retry blocked by circuit breaker');
return false;
}
// Check attempt limit
if (attempt >= condition.maxAttempts) {
this.logger?.debug(`Maximum attempts reached: ${attempt}/${condition.maxAttempts}`);
return false;
}
// Check time limit
if (elapsedTimeMs >= condition.maxTotalTimeMs) {
this.logger?.debug(`Maximum retry time exceeded: ${elapsedTimeMs}ms/${condition.maxTotalTimeMs}ms`);
return false;
}
// Custom retry decision
if (condition.shouldRetry) {
return condition.shouldRetry(error, attempt, elapsedTimeMs);
}
// Check if error type is retryable
const isRetryableType = condition.retryableErrorTypes.some(ErrorType => error instanceof ErrorType);
if (!isRetryableType) {
this.logger?.debug(`Error type not retryable: ${error.constructor.name}`);
return false;
}
// Specific retry logic based on error type
if (error instanceof RateLimitError) {
return error.canRetry();
}
if (error instanceof NetworkError) {
return error.isRetryable();
}
if (error instanceof GoveeApiError) {
// Retry on server errors (5xx) and some client errors
const retryableStatusCodes = [
408,
429,
500,
502,
503,
504,
...condition.retryableStatusCodes,
];
return retryableStatusCodes.includes(error.statusCode);
}
if (error instanceof InvalidApiKeyError) {
// Never retry authentication errors
return false;
}
return false;
}
/**
* Calculates the delay before the next retry attempt
*/
calculateDelay(attempt, error) {
const { backoff, jitter } = this.config;
// Handle rate limit errors with specific retry-after values
if (error instanceof RateLimitError && error.canRetry()) {
const rateLimitDelay = error.getRetryAfterMs();
return this.applyJitter(rateLimitDelay, attempt);
}
let baseDelay;
switch (backoff.type) {
case 'fixed':
baseDelay = backoff.initialDelayMs;
break;
case 'linear':
baseDelay = backoff.initialDelayMs * attempt;
break;
case 'exponential':
const multiplier = backoff.multiplier || 2;
baseDelay = backoff.initialDelayMs * Math.pow(multiplier, attempt - 1);
break;
case 'custom':
if (!backoff.customBackoff) {
throw new Error('Custom backoff function not provided');
}
baseDelay = backoff.customBackoff(attempt, error);
break;
default:
throw new Error(`Unknown backoff type: ${backoff.type}`);
}
// Ensure delay doesn't exceed maximum
baseDelay = Math.min(baseDelay, backoff.maxDelayMs);
return this.applyJitter(baseDelay, attempt);
}
/**
* Applies jitter to prevent thundering herd problems
*/
applyJitter(baseDelay, attempt) {
const { jitter } = this.config;
const factor = jitter.factor || 0.1;
switch (jitter.type) {
case 'none':
return baseDelay;
case 'full':
// Random value between 0 and baseDelay
return Math.random() * baseDelay;
case 'equal':
// baseDelay/2 + random(0, baseDelay/2)
return baseDelay / 2 + Math.random() * (baseDelay / 2);
case 'decorrelated':
// More sophisticated jitter that considers previous delays
const minDelay = this.config.backoff.initialDelayMs;
const maxJitter = baseDelay * factor;
return Math.min(this.config.backoff.maxDelayMs, Math.random() * (baseDelay * 3 - minDelay) + minDelay);
default:
throw new Error(`Unknown jitter type: ${jitter.type}`);
}
}
/**
* Records a successful operation
*/
recordSuccess() {
this.circuitBreaker?.recordSuccess();
this.updateMetrics(true);
}
/**
* Records a failed operation
*/
recordFailure(error) {
this.circuitBreaker?.recordFailure();
this.updateMetrics(false, error);
}
/**
* Updates retry metrics
*/
updateMetrics(success, error) {
if (!this.config.enableMetrics)
return;
this.metrics.totalAttempts++;
this.metrics.circuitBreakerState = this.circuitBreaker?.getState() || 'closed';
this.metrics.lastRetryTimestamp = new Date();
if (success) {
this.metrics.successfulRetries++;
}
else {
this.metrics.failedRetries++;
this.metrics.lastError = error;
}
// Update average delay (simplified calculation)
if (this.metrics.totalAttempts > 1) {
this.metrics.averageRetryDelayMs =
this.metrics.totalRetryTimeMs / (this.metrics.totalAttempts - 1);
}
}
/**
* Gets current retry metrics
*/
getMetrics() {
return { ...this.metrics };
}
/**
* Resets retry metrics and circuit breaker
*/
reset() {
this.circuitBreaker?.reset();
Object.assign(this.metrics, {
totalAttempts: 0,
successfulRetries: 0,
failedRetries: 0,
totalRetryTimeMs: 0,
averageRetryDelayMs: 0,
circuitBreakerState: this.circuitBreaker?.getState() || 'closed',
lastError: undefined,
lastRetryTimestamp: undefined,
});
this.logger?.info('Retry policy metrics and circuit breaker reset');
}
/**
* Creates a default retry policy optimized for Govee API
*/
static createGoveeOptimized(logger) {
return new RetryPolicy({
backoff: {
type: 'exponential',
initialDelayMs: 1000, // Start with 1 second
maxDelayMs: 30000, // Cap at 30 seconds
multiplier: 2.0,
},
jitter: {
type: 'equal',
factor: 0.1,
},
condition: {
maxAttempts: 3,
maxTotalTimeMs: 60000, // 1 minute total
retryableStatusCodes: [408, 502, 503, 504],
retryableErrorTypes: [RateLimitError, NetworkError, GoveeApiError],
},
circuitBreaker: {
enabled: true,
failureThreshold: 5,
recoveryTimeoutMs: 30000,
halfOpenSuccessThreshold: 2,
},
logger,
enableMetrics: true,
});
}
/**
* Creates a conservative retry policy for production environments
*/
static createConservative(logger) {
return new RetryPolicy({
backoff: {
type: 'exponential',
initialDelayMs: 2000,
maxDelayMs: 60000,
multiplier: 1.5,
},
jitter: {
type: 'decorrelated',
factor: 0.2,
},
condition: {
maxAttempts: 2,
maxTotalTimeMs: 120000,
retryableStatusCodes: [429, 502, 503, 504],
retryableErrorTypes: [RateLimitError, NetworkError],
},
circuitBreaker: {
enabled: true,
failureThreshold: 3,
recoveryTimeoutMs: 60000,
halfOpenSuccessThreshold: 3,
},
logger,
enableMetrics: true,
});
}
/**
* Creates an aggressive retry policy for development/testing
*/
static createAggressive(logger) {
return new RetryPolicy({
backoff: {
type: 'exponential',
initialDelayMs: 500,
maxDelayMs: 15000,
multiplier: 2.5,
},
jitter: {
type: 'full',
factor: 0.3,
},
condition: {
maxAttempts: 5,
maxTotalTimeMs: 45000,
retryableStatusCodes: [400, 408, 429, 500, 502, 503, 504],
retryableErrorTypes: [RateLimitError, NetworkError, GoveeApiError],
},
circuitBreaker: {
enabled: false,
failureThreshold: 10,
recoveryTimeoutMs: 15000,
halfOpenSuccessThreshold: 1,
},
logger,
enableMetrics: true,
});
}
}
//# sourceMappingURL=RetryPolicy.js.map