import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { FetchTimeoutError, fetchWithTimeout } from "@/shared/utils/fetchTimeout"; import { OutboundUrlGuardError, type OutboundUrlGuardMode, parseAndValidatePublicUrl, parseOutboundUrl, } from "@/shared/network/outboundUrlGuard"; const DEFAULT_IDEMPOTENT_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]; export type SafeOutboundFetchGuard = OutboundUrlGuardMode; export type SafeOutboundFetchErrorCode = | "INVALID_URL" | "URL_GUARD_BLOCKED" | "TIMEOUT" | "REDIRECT_BLOCKED" | "NETWORK_ERROR"; export interface SafeOutboundFetchRetryOptions { attempts?: number; backoffMs?: number | number[]; methods?: string[]; statusCodes?: number[]; } export interface SafeOutboundFetchOptions extends RequestInit { timeoutMs?: number; allowRedirect?: boolean; retry?: SafeOutboundFetchRetryOptions | false; guard?: SafeOutboundFetchGuard; proxyConfig?: unknown; } type SafeOutboundFetchPresetMap = { validationRead: SafeOutboundFetchOptions; validationWrite: SafeOutboundFetchOptions; modelsProbe: SafeOutboundFetchOptions; modelsDiscovery: SafeOutboundFetchOptions; modelsPagination: SafeOutboundFetchOptions; }; export const SAFE_OUTBOUND_FETCH_PRESETS: SafeOutboundFetchPresetMap = { validationRead: { timeoutMs: 5000, allowRedirect: false, retry: { attempts: 2, backoffMs: [150], methods: ["GET", "HEAD"], }, }, validationWrite: { timeoutMs: 7000, allowRedirect: false, retry: false, }, modelsProbe: { timeoutMs: 5000, allowRedirect: false, retry: { attempts: 2, backoffMs: [150], methods: ["GET", "HEAD"], }, }, modelsDiscovery: { timeoutMs: 10000, allowRedirect: false, retry: { attempts: 2, backoffMs: [200], methods: ["GET", "HEAD"], }, }, modelsPagination: { timeoutMs: 15000, allowRedirect: false, retry: { attempts: 2, backoffMs: [250], methods: ["GET", "HEAD"], }, }, }; type SafeOutboundFetchErrorInit = { code: SafeOutboundFetchErrorCode; url: string; method: string; attempts: number; isRetryable: boolean; timeoutMs?: number; status?: number; location?: string | null; cause?: unknown; }; export class SafeOutboundFetchError extends Error { code: SafeOutboundFetchErrorCode; url: string; method: string; attempts: number; isRetryable: boolean; timeoutMs?: number; status?: number; location?: string | null; constructor(message: string, init: SafeOutboundFetchErrorInit) { super(message); this.name = "SafeOutboundFetchError"; this.code = init.code; this.url = init.url; this.method = init.method; this.attempts = init.attempts; this.isRetryable = init.isRetryable; this.timeoutMs = init.timeoutMs; this.status = init.status; this.location = init.location ?? null; if (init.cause !== undefined) { (this as Error & { cause?: unknown }).cause = init.cause; } } } function normalizeMethod(method?: string) { return (method || "GET").toUpperCase(); } function normalizeUrl(input: string | URL) { try { return parseOutboundUrl(input); } catch (error) { if (error instanceof OutboundUrlGuardError) { throw new SafeOutboundFetchError(error.message, { code: error.code === "OUTBOUND_URL_INVALID" ? "INVALID_URL" : "URL_GUARD_BLOCKED", url: error.url, method: "GET", attempts: 1, isRetryable: false, cause: error, }); } throw new SafeOutboundFetchError(`Invalid outbound URL: ${String(input)}`, { code: "INVALID_URL", url: String(input), method: "GET", attempts: 1, isRetryable: false, cause: error, }); } } function applyUrlGuard(targetUrl: URL, guard: SafeOutboundFetchGuard, method: string) { if (guard !== "public-only") return; try { parseAndValidatePublicUrl(targetUrl); } catch (error) { if (error instanceof OutboundUrlGuardError) { throw new SafeOutboundFetchError(error.message, { code: error.code === "OUTBOUND_URL_INVALID" ? "INVALID_URL" : "URL_GUARD_BLOCKED", url: error.url, method, attempts: 1, isRetryable: false, cause: error, }); } throw error; } } function getRetryConfig(retry: SafeOutboundFetchRetryOptions | false | undefined, method: string) { if (retry === false) { return { attempts: 1, shouldRetryMethod: false, statusCodes: new Set(), backoffMs: [] as number[], }; } const methods = new Set( (retry?.methods || DEFAULT_IDEMPOTENT_METHODS).map((value) => value.toUpperCase()) ); const attempts = Math.max(1, retry?.attempts || 1); const backoffMs = Array.isArray(retry?.backoffMs) ? retry?.backoffMs : typeof retry?.backoffMs === "number" ? [retry.backoffMs] : []; const statusCodes = new Set(retry?.statusCodes || []); return { attempts, shouldRetryMethod: methods.has(method), statusCodes, backoffMs, }; } function getBackoffDelay(backoffMs: number[], attemptNumber: number) { if (backoffMs.length === 0) return 0; return backoffMs[Math.min(attemptNumber - 1, backoffMs.length - 1)] || 0; } function sleep(ms: number) { if (ms <= 0) return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, ms)); } async function cancelResponseBody(response: Response) { try { await response.body?.cancel(); } catch { // Ignore body cancellation errors when preparing a retry. } } function normalizeFetchFailure( error: unknown, targetUrl: string, method: string, attempts: number ): SafeOutboundFetchError { if (error instanceof SafeOutboundFetchError) { error.attempts = attempts; return error; } if (error instanceof FetchTimeoutError) { return new SafeOutboundFetchError(error.message, { code: "TIMEOUT", url: targetUrl, method, attempts, timeoutMs: error.timeoutMs, isRetryable: true, cause: error, }); } const message = error instanceof Error ? error.message : String(error); const code = error && typeof error === "object" ? (error as { code?: string }).code : undefined; return new SafeOutboundFetchError(message || `Outbound request failed for ${targetUrl}`, { code: "NETWORK_ERROR", url: targetUrl, method, attempts, isRetryable: code !== "PROXY_UNREACHABLE", cause: error, }); } export async function safeOutboundFetch(url: string | URL, options: SafeOutboundFetchOptions = {}) { const targetUrl = normalizeUrl(url); const method = normalizeMethod(options.method); const { timeoutMs, allowRedirect = false, retry, guard = "none", proxyConfig, signal, ...fetchOptions } = options; applyUrlGuard(targetUrl, guard, method); const retryConfig = getRetryConfig(retry, method); const redirect = allowRedirect ? (fetchOptions.redirect ?? "follow") : "manual"; for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) { try { const executeFetch = () => fetchWithTimeout(targetUrl.toString(), { ...fetchOptions, method, redirect, signal, timeoutMs, }); const response = proxyConfig ? await runWithProxyContext(proxyConfig, executeFetch) : await executeFetch(); if (!allowRedirect && response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); await cancelResponseBody(response); throw new SafeOutboundFetchError( `Redirect blocked for ${method} ${targetUrl.toString()} (${response.status})`, { code: "REDIRECT_BLOCKED", url: targetUrl.toString(), method, attempts: attempt, status: response.status, location, isRetryable: false, } ); } if ( retryConfig.shouldRetryMethod && attempt < retryConfig.attempts && retryConfig.statusCodes.has(response.status) ) { await cancelResponseBody(response); await sleep(getBackoffDelay(retryConfig.backoffMs, attempt)); continue; } return response; } catch (error) { const normalizedError = normalizeFetchFailure(error, targetUrl.toString(), method, attempt); const shouldRetry = retryConfig.shouldRetryMethod && attempt < retryConfig.attempts && normalizedError.isRetryable; if (!shouldRetry) { throw normalizedError; } await sleep(getBackoffDelay(retryConfig.backoffMs, attempt)); } } throw new SafeOutboundFetchError(`Outbound request failed for ${targetUrl.toString()}`, { code: "NETWORK_ERROR", url: targetUrl.toString(), method, attempts: retryConfig.attempts, isRetryable: false, }); } export function getSafeOutboundFetchErrorStatus(error: unknown) { if (!(error instanceof SafeOutboundFetchError)) return null; if (error.code === "TIMEOUT") return 504; if ( error.code === "INVALID_URL" || error.code === "URL_GUARD_BLOCKED" || error.code === "REDIRECT_BLOCKED" ) { return 503; } return null; }