All files / src/mcp/transport retry-policy.ts

93.93% Statements 31/33
91.17% Branches 31/34
100% Functions 3/3
96.77% Lines 30/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                      9x     9x     9x                     5x 5x 5x 5x 1x 1x 1x                                                 102x 5x   97x 102x 3x   94x                                             13x 13x 2x   11x 11x 7x   4x 4x 1x   3x 3x 2x 2x   1x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module MCP/transport/retry-policy
 * @description Retry-policy helpers, back-off constants, and Retry-After header parsing.
 */
 
import { MCPSessionExpiredError, MCPRateLimitError } from './errors.js';
 
/** Maximum reconnect back-off delay in milliseconds */
export const RECONNECT_MAX_DELAY_MS = 30000;
 
/** HTTP header for API rate-limit retry delay */
export const RETRY_AFTER_HEADER = 'X-Retry-After';
 
/** Log prefix for rate-limit warnings */
export const RATE_LIMIT_MSG = 'Rate limited. Retry after';
 
/**
 * Parse a `Retry-After` or `X-Retry-After` header value into milliseconds.
 * Accepts delta-seconds ("30"), numeric-with-suffix ("30s"), or an HTTP-date string.
 * Returns 0 when the value is empty or unparseable.
 *
 * @param retryAfter - Raw Retry-After / X-Retry-After header value
 * @returns Delay in milliseconds, or 0 if the value cannot be parsed
 */
export function parseRetryAfterMs(retryAfter: string): number {
  const normalized = retryAfter.trim().replace(/s$/i, '');
  Iif (!normalized) return 0;
  const numericDelay = Number(normalized);
  if (!Number.isNaN(numericDelay)) return numericDelay * 1000;
  const retryDate = new Date(retryAfter);
  Eif (!Number.isNaN(retryDate.getTime())) {
    return Math.max(0, retryDate.getTime() - Date.now());
  }
  return 0;
}
 
/**
 * Returns true only for transient, retriable failures: request timeouts,
 * network-level connection-closed/reset errors, "not connected" states,
 * and transient HTTP gateway errors (502, 503, 504).
 *
 * Uses an allow-list of known transient error patterns so that unknown or
 * server-level errors (e.g., tool runtime failures) are NOT retried:
 * - timeout — AbortSignal timeout or custom timeout message
 * - connection closed / reset / refused — network-level transport failures
 * - not connected — local "not yet connected" guard error
 * - socket hang up — Node.js HTTP socket-level disconnection
 * - gateway error 502/503/504 — transient upstream server errors
 *
 * Everything else (MCPSessionExpiredError, TypeError, rate-limit errors,
 * unknown errors) returns false so `callToolWithRetry` surfaces them immediately.
 *
 * @param error - The caught error to classify
 * @returns `true` if the error is safe to retry
 */
export function isRetriableError(error: Error): boolean {
  if (error instanceof MCPSessionExpiredError || error instanceof TypeError) {
    return false;
  }
  const msg = error.message?.toLowerCase() ?? '';
  if (error instanceof MCPRateLimitError || msg.startsWith(RATE_LIMIT_MSG.toLowerCase())) {
    return false;
  }
  return (
    msg.includes('timeout') ||
    msg.includes('connection closed') ||
    msg.includes('connection reset') ||
    msg.includes('not connected') ||
    msg.includes('econnreset') ||
    msg.includes('econnrefused') ||
    msg.includes('socket hang up') ||
    msg.includes('gateway error 502') ||
    msg.includes('gateway error 503') ||
    msg.includes('gateway error 504')
  );
}
 
/**
 * Parse a `Retry-After` or `X-Retry-After` header value (which may be either a
 * delay-in-seconds number, a numeric string with an optional trailing "s" suffix
 * (e.g. "30s"), or an HTTP-date string) into a human-readable message.
 *
 * @param retryAfter - Raw header value
 * @returns Formatted string describing the delay (e.g. "30s" or "45s (until Thu, 01 Jan 2026 …)")
 */
export function formatRetryAfter(retryAfter: string): string {
  const normalized = retryAfter.trim().replace(/s$/i, '');
  if (!normalized) {
    return retryAfter;
  }
  const numericDelay = Number(normalized);
  if (!Number.isNaN(numericDelay)) {
    return `${numericDelay}s`;
  }
  const retryDate = new Date(retryAfter);
  if (Number.isNaN(retryDate.getTime())) {
    return retryAfter;
  }
  const delayMs = retryDate.getTime() - Date.now();
  if (delayMs > 0) {
    const delaySeconds = Math.ceil(delayMs / 1000);
    return `${delaySeconds}s (until ${retryDate.toUTCString()})`;
  }
  return retryDate.toUTCString();
}