| import { runWithProxyContext, getOriginalFetch } 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;
|
| |
| |
|
|
| bypassProxyPatch?: boolean;
|
| }
|
|
|
| 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: 15000,
|
| 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<number>(),
|
| 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 {
|
|
|
| }
|
| }
|
|
|
| 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,
|
| bypassProxyPatch = false,
|
| 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,
|
|
|
| fetchFn: bypassProxyPatch ? getOriginalFetch() : undefined,
|
| });
|
|
|
| const response = bypassProxyPatch
|
| ? await executeFetch()
|
| : 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;
|
| }
|
|
|