@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
131 lines (130 loc) • 3.72 kB
JavaScript
/**
* Retry logic with exponential backoff
*/
import { NetworkError, RateLimitError } from '../errors/index.js';
const DEFAULT_OPTIONS = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 30000,
backoffFactor: 2,
retryableStatuses: [429, 500, 502, 503, 504],
};
/**
* Sleep for specified milliseconds
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Calculate delay with exponential backoff
*/
function calculateDelay(attempt, options) {
const delay = Math.min(options.initialDelay * Math.pow(options.backoffFactor, attempt - 1), options.maxDelay);
// Add jitter to prevent thundering herd
const jitter = Math.random() * 0.3 * delay;
return Math.floor(delay + jitter);
}
/**
* Determine if error is retryable
*/
function isRetryableError(error, options) {
if (error instanceof RateLimitError) {
return true;
}
if (error instanceof NetworkError) {
return true;
}
// Check axios error response
if (typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof error.response?.status === 'number') {
return options.retryableStatuses.includes(error.response.status);
}
return false;
}
/**
* Execute function with retry logic
*/
export async function withRetry(fn, options) {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError;
for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
}
catch (error) {
lastError = error;
// Check if we should retry
if (attempt === opts.maxRetries || !isRetryableError(error, opts)) {
throw error;
}
// Calculate delay
let delay = calculateDelay(attempt, opts);
// Use retry-after header if available
if (error instanceof RateLimitError && error.retryAfter) {
delay = error.retryAfter * 1000;
}
else if (typeof error === 'object' &&
error !== null &&
'response' in error &&
error.response?.headers?.['retry-after']) {
const retryAfter = parseInt(error.response.headers['retry-after']);
if (!isNaN(retryAfter)) {
delay = retryAfter * 1000;
}
}
// Wait before retrying
await sleep(delay);
}
}
throw lastError;
}
/**
* Circuit breaker pattern for API failures
*/
export class CircuitBreaker {
threshold;
timeout;
failures = 0;
lastFailureTime = 0;
state = 'closed';
constructor(threshold = 5, timeout = 60000 // 1 minute
) {
this.threshold = threshold;
this.timeout = timeout;
}
async execute(fn) {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'half-open';
}
else {
throw new NetworkError('Circuit breaker is open');
}
}
try {
const result = await fn();
if (this.state === 'half-open') {
this.reset();
}
return result;
}
catch (error) {
this.recordFailure();
throw error;
}
}
recordFailure() {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
}
}
reset() {
this.failures = 0;
this.lastFailureTime = 0;
this.state = 'closed';
}
}