Files
OmniRoute/src/shared/network/safeOutboundFetch.ts

357 lines
9.2 KiB
TypeScript

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<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 {
// 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;
}