@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
173 lines (172 loc) • 6.56 kB
JavaScript
/**
* Unit tests for retry logic
*/
import { describe, it, expect } from '@jest/globals';
import { withRetry, CircuitBreaker } from '../utils/retry.js';
import { NetworkError, RateLimitError } from '../errors/index.js';
describe('Retry Logic', () => {
describe('withRetry', () => {
it('should succeed on first attempt', async () => {
let attempts = 0;
const fn = async () => {
attempts++;
return 'success';
};
const result = await withRetry(fn);
expect(result).toBe('success');
expect(attempts).toBe(1);
});
it('should retry on retryable errors', async () => {
let attempts = 0;
const fn = async () => {
attempts++;
if (attempts < 3) {
throw new NetworkError('Connection failed');
}
return 'success';
};
const result = await withRetry(fn, {
maxRetries: 3,
initialDelay: 10,
});
expect(result).toBe('success');
expect(attempts).toBe(3);
});
it('should respect rate limit retry-after', async () => {
let attempts = 0;
const fn = async () => {
attempts++;
if (attempts === 1) {
throw new RateLimitError(1); // 1 second
}
return 'success';
};
const start = Date.now();
const result = await withRetry(fn, {
maxRetries: 2,
initialDelay: 10000, // Would be 10s without retry-after
});
const duration = Date.now() - start;
expect(result).toBe('success');
expect(attempts).toBe(2);
expect(duration).toBeGreaterThanOrEqual(900); // At least 900ms
expect(duration).toBeLessThan(2000); // But less than 2s
});
it('should fail after max retries', async () => {
let attempts = 0;
const error = new NetworkError('Persistent failure');
const fn = async () => {
attempts++;
throw error;
};
await expect(withRetry(fn, { maxRetries: 2, initialDelay: 10 })).rejects.toThrow(error);
expect(attempts).toBe(2);
});
it('should not retry non-retryable errors', async () => {
let attempts = 0;
const error = new Error('Not retryable');
const fn = async () => {
attempts++;
throw error;
};
await expect(withRetry(fn)).rejects.toThrow(error);
expect(attempts).toBe(1);
});
it('should handle axios-like errors', async () => {
let attempts = 0;
const fn = async () => {
attempts++;
if (attempts === 1) {
throw { response: { status: 503 } };
}
return 'success';
};
const result = await withRetry(fn, {
maxRetries: 2,
initialDelay: 10,
});
expect(result).toBe('success');
expect(attempts).toBe(2);
});
});
describe('CircuitBreaker', () => {
it('should allow calls when closed', async () => {
const breaker = new CircuitBreaker(3, 100);
let calls = 0;
const fn = async () => {
calls++;
return 'success';
};
const result = await breaker.execute(fn);
expect(result).toBe('success');
expect(calls).toBe(1);
});
it('should open after threshold failures', async () => {
const breaker = new CircuitBreaker(3, 100);
let calls = 0;
const fn = async () => {
calls++;
throw new Error('fail');
};
// Fail 3 times to open the breaker
for (let i = 0; i < 3; i++) {
await expect(breaker.execute(fn)).rejects.toThrow('fail');
}
expect(calls).toBe(3);
// Circuit should be open now
await expect(breaker.execute(fn)).rejects.toThrow('Circuit breaker is open');
expect(calls).toBe(3); // Not called on 4th attempt
});
it('should enter half-open state after timeout', async () => {
const breaker = new CircuitBreaker(2, 50); // 50ms timeout
let calls = 0;
let shouldFail = true;
const fn = async () => {
calls++;
if (shouldFail) {
throw new Error('fail');
}
return 'success';
};
// Open the breaker
await expect(breaker.execute(fn)).rejects.toThrow('fail');
await expect(breaker.execute(fn)).rejects.toThrow('fail');
expect(calls).toBe(2);
// Should be open
await expect(breaker.execute(fn)).rejects.toThrow('Circuit breaker is open');
expect(calls).toBe(2);
// Wait for timeout
await new Promise(resolve => setTimeout(resolve, 60));
// Should allow one attempt (half-open)
shouldFail = false;
const result = await breaker.execute(fn);
expect(result).toBe('success');
expect(calls).toBe(3);
});
it('should reset on successful half-open call', async () => {
const breaker = new CircuitBreaker(2, 50);
let calls = 0;
let failCount = 0;
const fn = async () => {
calls++;
if (failCount > 0) {
failCount--;
throw new Error('fail');
}
return 'success';
};
// Open the breaker
failCount = 2;
await expect(breaker.execute(fn)).rejects.toThrow();
await expect(breaker.execute(fn)).rejects.toThrow();
// Wait and succeed in half-open
await new Promise(resolve => setTimeout(resolve, 60));
await breaker.execute(fn);
// Should be fully closed now - reset call count
calls = 0;
await breaker.execute(fn);
await breaker.execute(fn);
expect(calls).toBe(2);
});
});
});