bc-webclient-mcp
Version:
Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server
324 lines • 13.5 kB
JavaScript
/**
* Retry Utilities Tests
*
* Tests for retry logic with exponential backoff, jitter, and AbortSignal support
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { retryWithBackoff, isRetryableAtConnectionBoundary, isRetryableError, } from './retry.js';
import { ok, err, isOk } from './result.js';
import { TimeoutError, AbortedError, ConnectionError, AuthenticationError, ProtocolError, ValidationError, SessionExpiredError, PermissionDeniedError, } from './errors.js';
describe('retry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('retryWithBackoff()', () => {
it('returns success on first attempt', async () => {
// Arrange
const fn = vi.fn(async () => ok('success'));
// Act
const promise = retryWithBackoff(fn, { maxAttempts: 2 });
const result = await promise;
// Assert
expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.value).toBe('success');
}
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries once and succeeds on second attempt', async () => {
// Arrange
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
if (callCount === 1) {
return err(new ConnectionError('temporary failure'));
}
return ok('success');
});
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 1,
initialDelayMs: 100,
jitter: false,
});
// Advance time to trigger retry
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
// Assert
expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.value).toBe('success');
}
expect(fn).toHaveBeenCalledTimes(2);
});
it('retries multiple times with exponential backoff', async () => {
// Arrange
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
if (callCount < 3) {
return err(new TimeoutError('timeout'));
}
return ok('success');
});
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 3,
initialDelayMs: 100,
backoffMultiplier: 2,
jitter: false,
});
// First retry: 100ms delay
await vi.advanceTimersByTimeAsync(100);
// Second retry: 200ms delay (exponential)
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
// Assert
expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.value).toBe('success');
}
expect(fn).toHaveBeenCalledTimes(3);
});
it('returns last error when max attempts reached', async () => {
// Arrange
const error = new ConnectionError('persistent failure');
const fn = vi.fn(async () => err(error));
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 2,
initialDelayMs: 50,
jitter: false,
});
await vi.advanceTimersByTimeAsync(50); // First retry
await vi.advanceTimersByTimeAsync(100); // Second retry
const result = await promise;
// Assert
expect(isOk(result)).toBe(false);
if (!isOk(result)) {
expect(result.error).toBe(error);
}
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
it('does not retry non-retryable errors', async () => {
// Arrange
const error = new AuthenticationError('invalid credentials');
const fn = vi.fn(async () => err(error));
// Act
const result = await retryWithBackoff(fn, {
maxAttempts: 2,
initialDelayMs: 100,
});
// Assert
expect(isOk(result)).toBe(false);
if (!isOk(result)) {
expect(result.error).toBe(error);
}
expect(fn).toHaveBeenCalledTimes(1); // No retries for AuthenticationError
});
it('respects custom isRetryable predicate', async () => {
// Arrange
const error = new ProtocolError('custom non-retryable');
const fn = vi.fn(async () => err(error));
const customIsRetryable = vi.fn(() => false); // Never retry
// Act
const result = await retryWithBackoff(fn, {
maxAttempts: 2,
isRetryable: customIsRetryable,
});
// Assert
expect(isOk(result)).toBe(false);
expect(fn).toHaveBeenCalledTimes(1);
expect(customIsRetryable).toHaveBeenCalledWith(error);
});
it('respects maxDelayMs ceiling', async () => {
// Arrange
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
if (callCount < 4) {
return err(new TimeoutError('timeout'));
}
return ok('success');
});
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 4,
initialDelayMs: 1000,
maxDelayMs: 2000, // Cap at 2 seconds
backoffMultiplier: 2,
jitter: false,
});
// First retry: 1000ms
await vi.advanceTimersByTimeAsync(1000);
// Second retry: 2000ms (would be 2000, capped)
await vi.advanceTimersByTimeAsync(2000);
// Third retry: 2000ms (would be 4000, capped at 2000)
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
// Assert
expect(isOk(result)).toBe(true);
expect(fn).toHaveBeenCalledTimes(4);
});
it('calls onRetry callback before each retry', async () => {
// Arrange
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
if (callCount < 3) {
return err(new ConnectionError('failure'));
}
return ok('success');
});
const onRetry = vi.fn();
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 2,
initialDelayMs: 50,
jitter: false,
onRetry,
});
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(100);
await promise;
// Assert
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenNthCalledWith(1, expect.any(ConnectionError), 1);
expect(onRetry).toHaveBeenNthCalledWith(2, expect.any(ConnectionError), 2);
});
describe('AbortSignal support', () => {
it('aborts immediately if signal is already aborted', async () => {
// Arrange
const controller = new AbortController();
controller.abort();
const fn = vi.fn(async () => ok('success'));
// Act
const result = await retryWithBackoff(fn, {
signal: controller.signal,
});
// Assert
expect(isOk(result)).toBe(false);
if (!isOk(result)) {
expect(result.error).toBeInstanceOf(AbortedError);
}
expect(fn).not.toHaveBeenCalled();
});
it('aborts during backoff delay', async () => {
// Arrange
const controller = new AbortController();
const fn = vi.fn(async () => err(new TimeoutError('timeout')));
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 2,
initialDelayMs: 1000,
signal: controller.signal,
jitter: false,
});
// Start delay, then abort during it
await vi.advanceTimersByTimeAsync(500);
controller.abort();
await vi.advanceTimersByTimeAsync(500);
const result = await promise;
// Assert
expect(isOk(result)).toBe(false);
if (!isOk(result)) {
expect(result.error).toBeInstanceOf(AbortedError);
expect(result.error.message).toContain('cancelled');
}
expect(fn).toHaveBeenCalledTimes(1); // Only initial attempt
});
it('aborts between attempts', async () => {
// Arrange
const controller = new AbortController();
let callCount = 0;
const fn = vi.fn(async () => {
callCount++;
return err(new TimeoutError('timeout'));
});
// Act
const promise = retryWithBackoff(fn, {
maxAttempts: 3,
initialDelayMs: 100,
signal: controller.signal,
jitter: false,
});
// Let first attempt complete
await vi.advanceTimersByTimeAsync(1);
// Abort before retry
controller.abort();
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
// Assert
expect(isOk(result)).toBe(false);
if (!isOk(result)) {
expect(result.error).toBeInstanceOf(AbortedError);
}
expect(fn).toHaveBeenCalledTimes(1); // Only first attempt before abort
});
});
});
describe('isRetryableAtConnectionBoundary()', () => {
it('returns true for TimeoutError', () => {
const error = new TimeoutError('timeout');
expect(isRetryableAtConnectionBoundary(error)).toBe(true);
});
it('returns true for ConnectionError', () => {
const error = new ConnectionError('connection failed');
expect(isRetryableAtConnectionBoundary(error)).toBe(true);
});
it('returns false for AbortedError', () => {
const error = new AbortedError('cancelled');
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
it('returns false for AuthenticationError', () => {
const error = new AuthenticationError('invalid credentials');
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
it('returns false for PermissionDeniedError', () => {
const error = new PermissionDeniedError('access denied');
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
it('returns false for ValidationError', () => {
const error = new ValidationError('invalid input');
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
it('returns false for SessionExpiredError', () => {
const error = new SessionExpiredError('session expired');
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
it('returns true for connection-related ProtocolError', () => {
const errors = [
new ProtocolError('Connection reset by peer'),
new ProtocolError('Network timeout occurred'),
new ProtocolError('Connection refused'),
new ProtocolError('Socket closed unexpectedly'),
];
errors.forEach(error => {
expect(isRetryableAtConnectionBoundary(error)).toBe(true);
});
});
it('returns false for non-connection ProtocolError', () => {
const errors = [
new ProtocolError('Invalid message format'),
new ProtocolError('Parse error'),
new ProtocolError('Unknown command'),
];
errors.forEach(error => {
expect(isRetryableAtConnectionBoundary(error)).toBe(false);
});
});
});
describe('isRetryableError()', () => {
it('uses isRetryableAtConnectionBoundary logic', () => {
// Should match behavior of isRetryableAtConnectionBoundary
expect(isRetryableError(new TimeoutError('timeout'))).toBe(true);
expect(isRetryableError(new ConnectionError('failed'))).toBe(true);
expect(isRetryableError(new AuthenticationError('invalid'))).toBe(false);
expect(isRetryableError(new ValidationError('bad input'))).toBe(false);
});
});
});
//# sourceMappingURL=retry.spec.js.map