All files / src/utils fetch.ts

89.75% Statements 254/283
82.26% Branches 167/203
92.85% Functions 26/28
90.47% Lines 247/273

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 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838                          104x 104x 104x                             104x 104x 104x     104x                 97x 91x     6x 6x 5x   5x 19x 19x 8x 8x 3x 3x 3x         5x   1x 1x               23x 20x     3x 3x 2x 2x   1x 1x               4x                                         23x     23x             23x 2x 2x 1x   2x 1x       23x 23x 2x     23x     23x 2x 2x 2x       21x 2x 2x             19x 4x     15x         104x     100x 23x 23x   100x           104x             101x 6x 6x 2x 4x 2x   2x   6x   95x                   104x                               104x 101x 101x   77x 2x       75x       104x 5x 5x                                                     17x     17x 2x     2x               2x 2x     17x 1x 1x         16x   15x     15x     16x 1x 1x 1x   1x                   179x 179x     175x 175x       175x       175x     175x                   175x 1400x 2x       175x       4x 4x     2x 2x 2x 2x   2x                   11x   7x       7x         7x 1x       6x             5x     1x               56x             17x 17x                       6x 6x 4x 4x 4x           2x 2x 1x   1x     1x                               97x 97x     97x 97x       97x   97x 7x 2x 3x   5x 2x 4x     3x       97x 2x     97x     97x 97x     97x 97x 97x       97x 97x     97x 7x   7x                         97x 97x 97x         97x           97x 8x     97x 97x   97x 97x 79x   79x 79x               79x   79x   18x 18x   18x                                                               18x               18x   18x                           104x 17x 17x   15x                     15x 15x 20x 20x 20x 20x     12x 12x     12x 12x               15x     15x       15x 15x   2x                                                                   104x       82x 82x 82x     82x 82x     82x                 82x                 82x 82x   3x             3x   3x                 1x       82x   82x 32x     50x   50x 65x 65x     56x   12x 12x 12x 6x 6x   5x       12x                     12x 12x         12x 10x     44x   11x     11x 5x   5x                 5x 5x       6x               82x 1x         104x 15x 15x    
/**
 * Enhanced fetch utilities for GitLab MCP Server
 *
 * Node.js v24 compatible implementation using Undici's dispatcher pattern.
 * Supports:
 * - TLS verification bypass (SKIP_TLS_VERIFY)
 * - Custom CA certificates (GITLAB_CA_CERT_PATH)
 * - HTTP/HTTPS proxy support (HTTP_PROXY, HTTPS_PROXY)
 * - Cookie authentication (GITLAB_AUTH_COOKIE_PATH)
 * - OAuth per-request token context
 * - Configurable timeout handling
 */
 
import * as fs from "fs";
import { logInfo, logWarn, logDebug } from "../logger";
import {
  SKIP_TLS_VERIFY,
  GITLAB_AUTH_COOKIE_PATH,
  GITLAB_CA_CERT_PATH,
  HTTP_PROXY,
  HTTPS_PROXY,
  NODE_TLS_REJECT_UNAUTHORIZED,
  GITLAB_TOKEN,
  GITLAB_BASE_URL,
  API_TIMEOUT_MS,
  API_RETRY_ENABLED,
  API_RETRY_MAX_ATTEMPTS,
  API_RETRY_BASE_DELAY_MS,
  API_RETRY_MAX_DELAY_MS,
} from "../config";
import { isOAuthEnabled, getTokenContext, getGitLabApiUrlFromContext } from "../oauth/index";
import { getRequestTracker } from "../logging/index";
import { InstanceRegistry } from "../services/InstanceRegistry.js";
 
// Dynamic require to avoid TypeScript analyzing complex undici types at compile time
const undici = require("undici") as {
  Agent: new (opts?: Record<string, unknown>) => unknown;
  ProxyAgent: new (opts: string | Record<string, unknown>) => unknown;
};
 
/**
 * Cookie handling - parse cookies from file and format for HTTP Cookie header
 */
function loadCookieHeader(): string | null {
  if (!GITLAB_AUTH_COOKIE_PATH) {
    return null;
  }
 
  try {
    const cookieString = fs.readFileSync(GITLAB_AUTH_COOKIE_PATH, "utf-8");
    const cookies: string[] = [];
 
    cookieString.split("\n").forEach(line => {
      const trimmed = line.trim();
      if (trimmed && !trimmed.startsWith("#")) {
        const parts = trimmed.split("\t");
        if (parts.length >= 7) {
          const name = parts[5];
          const value = parts[6];
          cookies.push(`${name}=${value}`);
        }
      }
    });
 
    return cookies.length > 0 ? cookies.join("; ") : null;
  } catch (error: unknown) {
    logWarn("Failed to load GitLab authentication cookies", { err: error });
    return null;
  }
}
 
/**
 * Load custom CA certificate
 */
function loadCACertificate(): Buffer | undefined {
  if (!GITLAB_CA_CERT_PATH) {
    return undefined;
  }
 
  try {
    const ca = fs.readFileSync(GITLAB_CA_CERT_PATH);
    logInfo(`Custom CA certificate loaded from ${GITLAB_CA_CERT_PATH}`);
    return ca;
  } catch (error: unknown) {
    logWarn(`Failed to load CA certificate from ${GITLAB_CA_CERT_PATH}`, { err: error });
    return undefined;
  }
}
 
/**
 * Check if URL is a SOCKS proxy
 */
function isSocksProxy(url: string): boolean {
  return url.startsWith("socks4://") || url.startsWith("socks5://") || url.startsWith("socks://");
}
 
/**
 * Create Undici dispatcher for fetch requests
 *
 * LIMITATION: This global dispatcher uses environment variables for TLS config.
 * Per-instance `insecureSkipVerify` settings are handled by InstanceConnectionPool
 * which creates per-instance dispatchers with correct TLS settings.
 *
 * This global dispatcher is used ONLY as fallback for:
 * - Requests to unregistered instances (not in config)
 * - Initialization before InstanceRegistry is ready
 *
 * For registered instances, enhancedFetch() uses registry.getDispatcher() which
 * returns the per-instance pool with proper TLS configuration.
 *
 * Security note: Global SKIP_TLS_VERIFY affects only fallback requests.
 * Production should configure instances in config file for per-instance TLS.
 */
function createDispatcher(): unknown {
  const proxyUrl = HTTPS_PROXY ?? HTTP_PROXY;
 
  // Build TLS options
  const tlsOptions: Record<string, unknown> = {};
 
  // SECURITY NOTE: Disabling certificate validation is an opt-in configuration
  // for self-hosted GitLab instances using self-signed certificates.
  // This is controlled by explicit environment variables (SKIP_TLS_VERIFY or
  // NODE_TLS_REJECT_UNAUTHORIZED=0) and is NOT enabled by default.
  // Users must consciously configure this for their private infrastructure.
  if (SKIP_TLS_VERIFY || NODE_TLS_REJECT_UNAUTHORIZED === "0") {
    tlsOptions.rejectUnauthorized = false;
    if (SKIP_TLS_VERIFY) {
      logWarn("TLS certificate verification disabled via SKIP_TLS_VERIFY");
    }
    if (NODE_TLS_REJECT_UNAUTHORIZED === "0") {
      logWarn("TLS certificate verification disabled via NODE_TLS_REJECT_UNAUTHORIZED");
    }
  }
 
  const ca = loadCACertificate();
  if (ca) {
    tlsOptions.ca = ca;
  }
 
  const hasTlsConfig = Object.keys(tlsOptions).length > 0;
 
  // SOCKS proxy not supported with native fetch
  if (proxyUrl && isSocksProxy(proxyUrl)) {
    logInfo(`Using SOCKS proxy: ${proxyUrl}`);
    logWarn("SOCKS proxy not supported with native fetch. Consider HTTP/HTTPS proxy.");
    return undefined;
  }
 
  // HTTP/HTTPS proxy
  if (proxyUrl) {
    logInfo(`Using proxy: ${proxyUrl}`);
    return new undici.ProxyAgent({
      uri: proxyUrl,
      requestTls: hasTlsConfig ? tlsOptions : undefined,
    });
  }
 
  // Custom TLS config without proxy
  if (hasTlsConfig) {
    return new undici.Agent({ connect: tlsOptions });
  }
 
  return undefined;
}
 
/** Cached dispatcher */
let cachedDispatcher: unknown;
let dispatcherInitialized = false;
 
function getDispatcher(): unknown {
  if (!dispatcherInitialized) {
    cachedDispatcher = createDispatcher();
    dispatcherInitialized = true;
  }
  return cachedDispatcher;
}
 
/**
 * Base HTTP headers
 */
export const DEFAULT_HEADERS: Record<string, string> = {
  "User-Agent": "GitLab MCP Server",
  "Content-Type": "application/json",
  Accept: "application/json",
};
 
function getGitLabToken(): string | undefined {
  if (isOAuthEnabled()) {
    const context = getTokenContext();
    if (!context) {
      logWarn("OAuth mode: no token context available - API call will fail with 401");
    } else if (!context.gitlabToken) {
      logWarn("OAuth mode: token context exists but no gitlabToken set");
    } else {
      logDebug("OAuth mode: using token from context", { userId: context.gitlabUserId });
    }
    return context?.gitlabToken;
  }
  return GITLAB_TOKEN;
}
 
/**
 * Get GitLab base URL from context or fallback to global config.
 * In OAuth mode, uses apiUrl from token context.
 * In static mode, uses GITLAB_BASE_URL from config.
 *
 * @returns The GitLab base URL (e.g., "https://gitlab.com")
 */
export function getGitLabBaseUrl(): string {
  if (isOAuthEnabled()) {
    const apiUrl = getGitLabApiUrlFromContext();
    if (apiUrl) {
      return apiUrl;
    }
    logWarn("OAuth mode: no API URL in context, falling back to global config");
  }
  return GITLAB_BASE_URL ?? "https://gitlab.com";
}
 
/**
 * Get authentication headers based on the current auth mode.
 * - Static mode (PAT): returns { "PRIVATE-TOKEN": token }
 * - OAuth mode: returns { "Authorization": "Bearer <token>" }
 */
export function getAuthHeaders(): Record<string, string> {
  const token = getGitLabToken();
  if (!token) return {};
 
  if (isOAuthEnabled()) {
    return { Authorization: `Bearer ${token}` };
  }
 
  // PAT mode: use GitLab's canonical PRIVATE-TOKEN header
  return { "PRIVATE-TOKEN": token };
}
 
/** @deprecated Use enhancedFetch() directly */
export function createFetchOptions(): Record<string, unknown> {
  const dispatcher = getDispatcher();
  return dispatcher ? { dispatcher } : {};
}
 
// ============================================================================
// Retry Logic
// ============================================================================
 
/**
 * Extended fetch options with retry configuration
 */
export interface FetchWithRetryOptions extends RequestInit {
  /** Enable retry for this request (default: true for GET/HEAD/OPTIONS, false otherwise) */
  retry?: boolean;
  /** Maximum number of retry attempts (default: from config) */
  maxRetries?: number;
  /** Enable per-instance rate limiting (default: true in multi-instance mode) */
  rateLimit?: boolean;
  /** Override the base URL for rate limit slot acquisition (derived from request URL if not specified) */
  rateLimitBaseUrl?: string;
}
 
/**
 * Sleep for a specified duration with optional abort support
 * @param ms - Duration to sleep in milliseconds
 * @param signal - Optional AbortSignal to cancel the sleep early
 */
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    // Helper to get abort error - ensures we reject with an AbortError-typed instance
    // Preserves AbortError semantics so downstream code can identify abort errors
    const getAbortError = (): Error => {
      const reason: unknown = signal?.reason;
 
      // If reason is already an Error, ensure it's identifiable as AbortError
      Iif (reason instanceof Error) {
        if (reason.name !== "AbortError") {
          reason.name = "AbortError";
        }
        return reason;
      }
 
      // For non-Error reasons, create DOMException with AbortError name
      const message = reason !== undefined ? String(reason) : "Aborted";
      return new DOMException(message, "AbortError");
    };
 
    if (signal?.aborted) {
      reject(getAbortError());
      return;
    }
 
    let abortHandler: (() => void) | undefined;
 
    const timeoutId = setTimeout(() => {
      // Clean up abort listener on normal completion
      Iif (abortHandler) {
        signal?.removeEventListener("abort", abortHandler);
      }
      resolve();
    }, ms);
 
    if (signal) {
      abortHandler = () => {
        clearTimeout(timeoutId);
        reject(getAbortError());
      };
      signal.addEventListener("abort", abortHandler, { once: true });
    }
  });
}
 
/**
 * Redact sensitive information from URLs for safe logging
 * Masks upload secrets, tokens in paths, and sensitive query parameters
 */
function redactUrlForLogging(url: string): string {
  try {
    const parsed = new URL(url);
 
    // Redact URL userinfo (user:pass@host)
    Iif (parsed.username) parsed.username = "[REDACTED]";
    Iif (parsed.password) parsed.password = "[REDACTED]";
 
    // Redact upload secrets in path: /uploads/<secret>/<filename> -> /uploads/[REDACTED]/<filename>
    // Secret can be any string (not just hex), so match any path segment after /uploads/
    parsed.pathname = parsed.pathname.replace(/\/uploads\/([^/]+)\//gi, "/uploads/[REDACTED]/");
 
    // Redact any path segment that looks like a secret/token (32+ hex chars)
    // Match both mid-path (/token/) and end-of-path (/token) tokens
    parsed.pathname = parsed.pathname.replace(/\/([a-f0-9]{32,})(\/|$)/gi, "/[REDACTED]$2");
 
    // Redact sensitive query parameters
    const sensitiveParams = [
      "private_token",
      "access_token",
      "oauth_token",
      "token",
      "secret",
      "key",
      "password",
      "auth",
    ];
    for (const param of sensitiveParams) {
      if (parsed.searchParams.has(param)) {
        parsed.searchParams.set(param, "[REDACTED]");
      }
    }
 
    return parsed.toString();
  } catch {
    // If URL parsing fails, return a safe fallback
    // Extract only scheme and host, excluding any userinfo (user:pass@)
    const schemeMatch = url.match(/^(https?):\/\//);
    if (!schemeMatch) return "[INVALID_URL]";
 
    // Remove userinfo if present and extract host
    const afterScheme = url.slice(schemeMatch[0].length);
    const atIndex = afterScheme.indexOf("@");
    const hostPart = atIndex >= 0 ? afterScheme.slice(atIndex + 1) : afterScheme;
    const hostMatch = hostPart.match(/^([^/:]+)/);
 
    return hostMatch ? `${schemeMatch[1]}://[REDACTED_HOST]/[URL_PARSE_ERROR]` : "[INVALID_URL]";
  }
}
 
/**
 * Determine if an error is retryable
 * Retryable errors: internal timeouts, network errors
 * NOT retryable: caller-initiated aborts (AbortError from caller signal)
 */
function isRetryableError(error: unknown): boolean {
  if (!(error instanceof Error)) return false;
 
  const message = error.message.toLowerCase();
 
  // Caller-initiated AbortErrors are NOT retryable
  // (doFetch converts internal timeouts to "GitLab API timeout" message)
  Iif (error.name === "AbortError") {
    return false;
  }
 
  // Internal timeout errors (converted by doFetch) are retryable
  if (message.includes("gitlab api timeout")) {
    return true;
  }
 
  // Network errors (fetch failures) are retryable
  if (
    message.includes("econnrefused") ||
    message.includes("econnreset") ||
    message.includes("etimedout") ||
    message.includes("enotfound") ||
    message.includes("network")
  ) {
    return true;
  }
 
  return false;
}
 
/**
 * Determine if an HTTP response status is retryable
 * 5xx server errors are retryable, 429 rate limit is retryable after delay
 */
function isRetryableStatus(status: number): boolean {
  return status >= 500 || status === 429;
}
 
/**
 * Calculate exponential backoff delay
 */
function calculateBackoffDelay(attempt: number): number {
  const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
  return Math.min(delay, API_RETRY_MAX_DELAY_MS);
}
 
/**
 * Parse Retry-After header value
 * Supports both delta-seconds (integer) and HTTP-date (RFC 7231) formats
 * @returns delay in milliseconds, or null if parsing fails
 */
function parseRetryAfter(retryAfter: string): number | null {
  // Try delta-seconds first (most common)
  // Accept 0 or positive integers per RFC 7231 (0 means "retry immediately")
  // Allow leading zeros per RFC 7231 delta-seconds = 1*DIGIT (e.g., "01", "001")
  const trimmed = retryAfter.trim();
  if (/^\d+$/.test(trimmed)) {
    const seconds = parseInt(trimmed, 10);
    Eif (seconds >= 0) {
      return seconds * 1000;
    }
  }
 
  // Try HTTP-date format (RFC 7231)
  // Example: "Wed, 21 Oct 2015 07:28:00 GMT"
  const dateMs = Date.parse(retryAfter);
  if (!isNaN(dateMs)) {
    const delayMs = dateMs - Date.now();
    // Only return positive delays
    return delayMs > 0 ? delayMs : null;
  }
 
  return null;
}
 
/**
 * Perform a single fetch request with timeout
 * Internal function used by enhancedFetch
 * @param url - The URL to fetch
 * @param options - Standard fetch RequestInit options
 * @param instanceDispatcher - Optional per-instance Undici dispatcher for HTTP/2 pooling
 */
async function doFetch(
  url: string,
  options: RequestInit = {},
  instanceDispatcher?: unknown
): Promise<Response> {
  // Use per-instance dispatcher if provided, otherwise fall back to global
  const dispatcher = instanceDispatcher ?? getDispatcher();
  const cookieHeader = loadCookieHeader();
 
  // For FormData, don't set Content-Type - let fetch set it with proper boundary
  const isFormData = options.body instanceof FormData;
  const baseHeaders = isFormData
    ? { "User-Agent": DEFAULT_HEADERS["User-Agent"], Accept: DEFAULT_HEADERS.Accept }
    : { ...DEFAULT_HEADERS };
 
  const headers: Record<string, string> = { ...baseHeaders, ...getAuthHeaders() };
 
  if (options.headers) {
    if (options.headers instanceof Headers) {
      options.headers.forEach((value, key) => {
        headers[key] = value;
      });
    } else if (Array.isArray(options.headers)) {
      for (const [key, value] of options.headers) {
        headers[key] = value;
      }
    } else {
      Object.assign(headers, options.headers);
    }
  }
 
  if (cookieHeader) {
    headers.Cookie = cookieHeader;
  }
 
  const method = (options.method ?? "GET").toUpperCase();
 
  // Debug log at request start (redact sensitive URL parts)
  const safeUrl = redactUrlForLogging(url);
  logDebug("Starting GitLab API request", { url: safeUrl, method, timeout: API_TIMEOUT_MS });
 
  // Use a unique Symbol to identify internal timeout aborts vs caller aborts
  const TIMEOUT_REASON = Symbol("GitLab API timeout");
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(TIMEOUT_REASON), API_TIMEOUT_MS);
 
  // Merge caller signal with internal timeout signal
  // Use AbortSignal.any() if available (Node.js 20+), otherwise use listener pattern
  let mergedSignal: AbortSignal = controller.signal;
  const callerSignal = options.signal as AbortSignal | undefined;
  let callerAbortHandler: (() => void) | undefined;
 
  if (callerSignal) {
    if (typeof AbortSignal.any === "function") {
      // Node.js 20+ - use AbortSignal.any for clean signal merging
      mergedSignal = AbortSignal.any([controller.signal, callerSignal]);
    } else E{
      // Fallback for older Node.js - forward caller abort to our controller
      if (callerSignal.aborted) {
        controller.abort(callerSignal.reason);
      } else {
        callerAbortHandler = () => controller.abort(callerSignal.reason);
        callerSignal.addEventListener("abort", callerAbortHandler, { once: true });
      }
    }
  }
 
  // Helper to clean up listeners
  const cleanup = () => {
    clearTimeout(timeoutId);
    Iif (callerAbortHandler && callerSignal) {
      callerSignal.removeEventListener("abort", callerAbortHandler);
    }
  };
 
  const fetchOptions: Record<string, unknown> = {
    ...options,
    headers,
    signal: mergedSignal,
  };
 
  if (dispatcher) {
    fetchOptions.dispatcher = dispatcher;
  }
 
  const startTime = Date.now();
  const requestTracker = getRequestTracker();
 
  try {
    const response = await fetch(url, fetchOptions as RequestInit);
    cleanup();
 
    const duration = Date.now() - startTime;
    logDebug("GitLab API request completed", {
      url: safeUrl,
      method,
      status: response.status,
      duration,
    });
 
    // Capture GitLab response for access logging
    requestTracker.setGitLabResponseForCurrentRequest(response.status, duration);
 
    return response;
  } catch (error) {
    cleanup();
    const duration = Date.now() - startTime;
 
    Iif (error instanceof Error && error.name === "AbortError") {
      // Distinguish between internal timeout and caller abort
      // Check if our internal controller was aborted with timeout reason
      const isInternalTimeout =
        controller.signal.aborted && controller.signal.reason === TIMEOUT_REASON;
 
      if (isInternalTimeout) {
        // Internal timeout - log and throw timeout error
        logWarn("GitLab API request timed out", {
          url: safeUrl,
          method,
          timeout: API_TIMEOUT_MS,
          duration,
        });
 
        // Capture timeout for access logging
        requestTracker.setGitLabResponseForCurrentRequest("timeout", duration);
 
        throw new Error(`GitLab API timeout after ${API_TIMEOUT_MS}ms`);
      } else {
        // Caller abort - re-throw original error to preserve abort reason
        logDebug("GitLab API request aborted by caller", {
          url: safeUrl,
          method,
          duration,
          reason: callerSignal?.reason,
        });
        throw error;
      }
    }
 
    // Log other errors with full error object for stack trace
    logWarn("GitLab API request failed", {
      url: safeUrl,
      method,
      err: error instanceof Error ? error : new Error(String(error)),
      duration,
    });
 
    // Capture error for access logging
    requestTracker.setGitLabResponseForCurrentRequest("error", duration);
 
    throw error;
  }
}
 
/**
 * Extract base URL from a full URL for rate limit slot acquisition
 *
 * For GitLab deployments this preserves any leading subpath (e.g.,
 * https://example.com/gitlab) and strips known API suffixes such as
 * /api/v4 and /api/graphql so that the result matches InstanceRegistry
 * normalization rules.
 *
 * @internal Exported for testing purposes
 */
export function extractBaseUrl(url: string): string | undefined {
  try {
    const parsed = new URL(url);
 
    let basePath = parsed.pathname || "/";
 
    // Strip known GitLab API suffixes while preserving any leading subpath.
    // Handles suffix in MIDDLE of path (e.g., /gitlab/api/v4/projects → /gitlab).
    // endsWith() won't work here — suffix may not be at end of URL.
    //
    // Nested loop skips partial matches (e.g., /api/v4foo/real/api/v4).
    // indexOf finds candidates, inner while verifies complete segment match.
    //
    // Performance: O(n*m) where n=path length, m=2 suffixes. Acceptable for
    // short paths (~100 chars), runs once per request. Correctness > speed.
    const apiSuffixes = ["/api/v4", "/api/graphql"];
    outerLoop: for (const suffix of apiSuffixes) {
      let searchPos = 0;
      while (searchPos < basePath.length) {
        const suffixIndex = basePath.indexOf(suffix, searchPos);
        if (suffixIndex === -1) break;
 
        // Verify the match is a complete segment (not partial like /api/v4foo)
        const afterSuffix = basePath.charAt(suffixIndex + suffix.length);
        Eif (afterSuffix === "" || afterSuffix === "/") {
          // Found complete API suffix — immediately exit both loops via labeled break.
          // Inner while only continues for PARTIAL matches (e.g., /api/v4foo).
          basePath = suffixIndex === 0 ? "/" : basePath.slice(0, suffixIndex);
          break outerLoop;
        }
        // Continue searching after this partial match
        searchPos = suffixIndex + 1;
      }
    }
 
    // Normalize path: ensure leading slash and remove trailing slash (except root).
    Iif (!basePath.startsWith("/")) {
      basePath = `/${basePath}`;
    }
    Iif (basePath.length > 1 && basePath.endsWith("/")) {
      basePath = basePath.slice(0, -1);
    }
 
    const origin = `${parsed.protocol}//${parsed.host}`;
    return basePath === "/" ? origin : `${origin}${basePath}`;
  } catch {
    return undefined;
  }
}
 
/**
 * Enhanced fetch with GitLab support, retry logic, and Node.js v24 compatibility
 *
 * @param url - URL to fetch
 * @param options - Fetch options with optional retry configuration
 * @returns Response from the server
 *
 * Retry behavior:
 * - By default, safe/read-only methods (GET/HEAD/OPTIONS) may be retried when
 *   global API retry is enabled.
 * - Other methods (e.g. POST/PUT/DELETE/PATCH) do NOT retry by default.
 * - Override per request with options.retry = true or false.
 * - Retries on: internal timeouts, network errors, 5xx responses, and 429 Too Many Requests.
 *   For 429, the Retry-After header is honored when present (delta-seconds or HTTP-date).
 * - Caller-provided AbortSignal aborts are NOT retried - they propagate immediately.
 * - Uses exponential backoff (configurable via API_RETRY_* settings).
 *
 * Rate limiting:
 * - When rateLimit option is true (default: true), acquires a rate limit slot
 *   from InstanceRegistry before making the request.
 * - The rate limit slot is automatically released after the request completes.
 * - Rate limiting is per-instance, allowing different GitLab servers to have
 *   independent rate limits.
 *
 * Timing considerations:
 * - With retries enabled (default for GET), worst-case time is:
 *   (maxRetries + 1) * timeout + sum of backoff delays
 * - Default: 4 attempts * 10s timeout + ~7s delays = ~47s worst case
 * - Disable retries (options.retry = false) for time-sensitive operations
 */
export async function enhancedFetch(
  url: string,
  options: FetchWithRetryOptions = {}
): Promise<Response> {
  const method = (options.method ?? "GET").toUpperCase();
  const isIdempotent = method === "GET" || method === "HEAD" || method === "OPTIONS";
  const safeUrl = redactUrlForLogging(url);
 
  // Determine if retry is enabled for this request
  const shouldRetry = options.retry ?? (API_RETRY_ENABLED && isIdempotent);
  const maxRetries = options.maxRetries ?? API_RETRY_MAX_ATTEMPTS;
 
  // Determine if rate limiting is enabled (default: true)
  const shouldRateLimit = options.rateLimit !== false;
 
  // Extract options to pass clean options to doFetch
  const {
    retry: _retry,
    maxRetries: _maxRetries,
    rateLimit: _rateLimit,
    rateLimitBaseUrl: _rateLimitBaseUrl,
    ...fetchOptions
  } = options;
 
  // Acquire rate limit slot and get per-instance dispatcher if enabled
  // NOTE: The slot is held for the entire request lifecycle including retries.
  // This is intentional - during retries the request is still "in progress" from
  // the server's perspective, and releasing/re-acquiring could allow queue jumping.
  let releaseSlot: (() => void) | undefined;
  let instanceDispatcher: unknown;
 
  const registry = InstanceRegistry.getInstance();
  if (registry.isInitialized()) {
    // Determine base URL for rate limiting and connection pooling
    const baseUrl = options.rateLimitBaseUrl ?? extractBaseUrl(url) ?? getGitLabBaseUrl();
 
    // Get per-instance HTTP/2 dispatcher for connection pooling.
    // InstanceRegistry.getDispatcher() lazily creates the pool for registered instances,
    // ensuring per-instance TLS settings (e.g., insecureSkipVerify) are applied even
    // for REST-only calls that happen before any GraphQL calls.
    // Falls back to global dispatcher only if instance is not registered at all.
    instanceDispatcher = registry.getDispatcher(baseUrl);
 
    if (shouldRateLimit) {
      // acquireSlot throws if rate limit exceeded - let it propagate
      //
      // NOTE: Slot is held for the entire retry loop including backoff sleeps.
      // This is intentional: during 429/retry scenarios, keeping the slot prevents
      // new requests from being queued while we're already at the rate limit.
      // Trade-off: slightly reduced throughput under retry conditions, but better
      // protection against overwhelming the GitLab instance with concurrent retries.
      // Alternative (release during backoff) risks thundering herd when retries complete.
      releaseSlot = await registry.acquireSlot(baseUrl);
    }
  }
 
  try {
    // If retry is disabled, just do a single fetch
    if (!shouldRetry || maxRetries <= 0) {
      return await doFetch(url, fetchOptions, instanceDispatcher);
    }
 
    let lastError: Error | null = null;
 
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const response = await doFetch(url, fetchOptions, instanceDispatcher);
 
        // Check if response status is retryable (5xx, 429)
        if (isRetryableStatus(response.status) && attempt < maxRetries) {
          // For 429, check Retry-After header (supports delta-seconds and HTTP-date)
          let retryDelay = calculateBackoffDelay(attempt);
          const retryAfter = response.headers.get("Retry-After");
          if (retryAfter && response.status === 429) {
            const parsedDelay = parseRetryAfter(retryAfter);
            if (parsedDelay !== null) {
              // Cap Retry-After to max delay to prevent excessive waits
              retryDelay = Math.min(parsedDelay, API_RETRY_MAX_DELAY_MS);
            }
          }
 
          logWarn("Retrying request after server error", {
            url: safeUrl,
            method,
            status: response.status,
            attempt: attempt + 1,
            maxRetries,
            retryDelay,
          });
 
          // Cancel response body to release connection before retry
          // Wrap in try-catch as cancel() can throw if body is already disturbed
          try {
            await response.body?.cancel();
          } catch {
            // Body already consumed or errored - safe to ignore
          }
 
          await sleep(retryDelay, fetchOptions.signal ?? undefined);
          continue;
        }
 
        return response;
      } catch (error) {
        lastError = error instanceof Error ? error : new Error(String(error));
 
        // Check if error is retryable and we have attempts left
        if (isRetryableError(error) && attempt < maxRetries) {
          const retryDelay = calculateBackoffDelay(attempt);
 
          logWarn("Retrying request after error", {
            url: safeUrl,
            method,
            error: lastError.message,
            attempt: attempt + 1,
            maxRetries,
            retryDelay,
          });
 
          await sleep(retryDelay, fetchOptions.signal ?? undefined);
          continue;
        }
 
        // Not retryable or no attempts left
        throw lastError;
      }
    }
 
    /* istanbul ignore next -- unreachable: loop always exits via return or throw */
    throw lastError ?? new Error("Unexpected: retry loop exited without result");
  } finally {
    // Always release rate limit slot
    if (releaseSlot) {
      releaseSlot();
    }
  }
}
 
export function resetDispatcherCache(): void {
  cachedDispatcher = undefined;
  dispatcherInitialized = false;
}