UNPKG

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

251 lines 9.37 kB
/** * Retry Utilities * * Provides robust retry logic with exponential backoff, jitter, and * intelligent error classification for Business Central operations. * * Key Features: * - Exponential backoff with jitter to prevent thundering herd * - AbortSignal support for cancellation * - Intelligent error classification (retryable vs non-retryable) * - Connection boundary retry guards * - Type-safe Result<T, E> integration */ import { err, isOk } from './result.js'; import { TimeoutError, AbortedError, ConnectionError, WebSocketConnectionError, NetworkError, AuthenticationError, SessionExpiredError, ProtocolError, ValidationError, PermissionDeniedError, } from './errors.js'; import { isTimeoutAbortReason, wasExternallyAborted } from './abort.js'; import { createConnectionLogger } from './logger.js'; const logger = createConnectionLogger('Retry', 'retryWithBackoff'); /** * Retries an async operation with exponential backoff and intelligent error handling. * * @param fn - Async function returning Result<T, BCError> * @param options - Retry configuration * @returns Result<T, BCError> - Final result or last error * * @example * ```ts * const result = await retryWithBackoff( * () => client.connect(signal), * { maxAttempts: 2, initialDelayMs: 1000 } * ); * ``` */ export async function retryWithBackoff(fn, options = {}) { const { maxAttempts = 1, initialDelayMs = 1000, maxDelayMs = 10_000, backoffMultiplier = 2, jitter = true, isRetryable = isRetryableError, signal, onRetry, } = options; // Check for pre-aborted signal if (signal?.aborted) { if (wasExternallyAborted(signal)) { return err(new AbortedError('Operation cancelled before starting', { reason: signal.reason, })); } else { return err(new TimeoutError('Operation timed out before starting', { reason: signal.reason, })); } } let lastError; let delayMs = initialDelayMs; // Attempt loop: initial attempt + retries for (let attempt = 0; attempt <= maxAttempts; attempt++) { // Check for abort before each attempt if (signal?.aborted) { if (wasExternallyAborted(signal)) { return err(new AbortedError('Operation cancelled during retry', { reason: signal.reason, attempt, lastError: lastError?.message, })); } else { return err(new TimeoutError('Operation timed out during retry', { reason: signal.reason, attempt, lastError: lastError?.message, })); } } // Execute the operation const result = await fn(); // Success! Return immediately if (isOk(result)) { if (attempt > 0) { logger.info({ attempt, maxAttempts }, `Operation succeeded after ${attempt} ${attempt === 1 ? 'retry' : 'retries'}`); } return result; } // Failure - store the error lastError = result.error; // If this was the last attempt, return the error if (attempt === maxAttempts) { logger.warn({ error: lastError, attempts: attempt + 1 }, 'Operation failed after all retry attempts'); return result; } // Check if error is retryable if (!isRetryable(lastError)) { logger.debug({ error: lastError, errorType: lastError.name }, 'Error is not retryable, aborting retry loop'); return result; } // Invoke onRetry callback (attempt is 1-based for user-facing) if (onRetry) { onRetry(lastError, attempt + 1); } // Calculate delay with exponential backoff const baseDelay = Math.min(delayMs, maxDelayMs); const jitterAmount = jitter ? Math.random() * baseDelay * 0.3 : 0; // ±30% jitter const actualDelay = Math.floor(baseDelay + jitterAmount); logger.debug({ attempt: attempt + 1, delayMs: actualDelay, error: lastError.message }, `Retrying after delay...`); // Wait before retry (with abort support) const delayResult = await delay(actualDelay, signal); if (!isOk(delayResult)) { // Aborted during delay return delayResult; } // Increase delay for next iteration (exponential backoff) delayMs *= backoffMultiplier; } // Should never reach here, but TypeScript needs exhaustiveness return err(lastError ?? new ProtocolError('Retry loop completed without result', { maxAttempts })); } /** * Determines if an error is retryable at the connection boundary. * * Connection boundary retries are appropriate for transient network issues, * timeouts, and certain WebSocket errors that may resolve on reconnection. * * NOT retryable: * - AbortedError (external cancellation) * - AuthenticationError (credentials wrong) * - PermissionDeniedError (authorization issue) * - ValidationError (bad input data) * - SessionExpiredError (requires re-authentication, not just retry) * * Retryable: * - TimeoutError (transient) * - ConnectionError (transient) * - WebSocketConnectionError (transient) * - NetworkError (transient) * - Certain ProtocolErrors (transient) * * @param error - The error to check * @returns true if the error is retryable at connection boundary * * @example * ```ts * if (isRetryableAtConnectionBoundary(error)) { * return await retryWithBackoff(() => client.connect()); * } * ``` */ export function isRetryableAtConnectionBoundary(error) { // Explicitly non-retryable errors if (error instanceof AbortedError || error instanceof AuthenticationError || error instanceof PermissionDeniedError || error instanceof ValidationError || error instanceof SessionExpiredError) { return false; } // Retryable transient errors if (error instanceof TimeoutError || error instanceof ConnectionError || error instanceof WebSocketConnectionError || error instanceof NetworkError) { return true; } // ProtocolError: retryable if it's a transient issue (connection-related) if (error instanceof ProtocolError) { const message = error.message.toLowerCase(); // Retry if message suggests transient network/connection issue if (message.includes('connection') || message.includes('timeout') || message.includes('network') || message.includes('refused') || message.includes('reset') || message.includes('closed')) { return true; } // Don't retry protocol errors that suggest permanent issues return false; } // Default: don't retry unknown error types return false; } /** * Determines if an error is retryable (general heuristic). * * This is a more permissive check than isRetryableAtConnectionBoundary, * suitable for internal operations where retry is safe. * * @param error - The error to check * @returns true if the error is retryable */ export function isRetryableError(error) { // Use connection boundary logic as baseline return isRetryableAtConnectionBoundary(error); } /** * Delays execution for a specified time, with AbortSignal support. * * @param ms - Milliseconds to delay * @param signal - Optional AbortSignal for cancellation * @returns Result<void, BCError> * * @example * ```ts * const result = await delay(1000, signal); * if (!isOk(result)) { * // Aborted during delay * } * ``` */ async function delay(ms, signal) { return new Promise((resolve) => { // Check for pre-aborted signal if (signal?.aborted) { if (wasExternallyAborted(signal)) { resolve(err(new AbortedError('Delay cancelled', { reason: signal.reason }))); } else { resolve(err(new TimeoutError('Delay timed out', { reason: signal.reason }))); } return; } let timeoutId; let abortListener; const cleanup = () => { if (timeoutId !== undefined) { clearTimeout(timeoutId); timeoutId = undefined; } if (abortListener && signal) { signal.removeEventListener('abort', abortListener); abortListener = undefined; } }; // Set up timeout timeoutId = setTimeout(() => { cleanup(); resolve({ ok: true, value: undefined }); }, ms); // Set up abort listener if (signal) { abortListener = () => { cleanup(); if (isTimeoutAbortReason(signal.reason)) { resolve(err(new TimeoutError('Delay aborted by timeout', { reason: signal.reason, }))); } else { resolve(err(new AbortedError('Delay cancelled', { reason: signal.reason }))); } }; signal.addEventListener('abort', abortListener, { once: true }); } }); } //# sourceMappingURL=retry.js.map