183 lines · 6.5 KB · TypeScript
RawDownload
import type { NextRequest } from "next/server";import type { ClientInfo } from "./proxy-logging";import { getUpstreamTimeoutMs } from "./proxy-timeouts";import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; export function isAbortError(error: unknown): boolean {  return (    error instanceof Error &&    (error.name === "AbortError" || error.message.toLowerCase().includes("aborted"))  );} function isRetriableConnectionError(error: unknown): boolean {  if (isAbortError(error)) return false;  const code = (error as { cause?: { code?: string } } | undefined)?.cause?.code;  if (code) {    return (      code === "ECONNRESET" ||      code === "EPIPE" ||      code === "ETIMEDOUT" ||      code === "UND_ERR_SOCKET" ||      code === "UND_ERR_CONNECT_TIMEOUT"    );  }  const message = (error instanceof Error ? error.message : String(error)).toLowerCase();  return message.includes("fetch failed") || message.includes("terminated");} function shouldFallbackFromResponse(response: Response): boolean {  if (response.ok) return false;  if (response.status !== 404) return false;  const contentType = response.headers.get("content-type") || "";  return contentType.includes("text/plain");} export function buildTargetUrl(backendUrl: string, path: string[], searchParams: string): string {  return `${backendUrl}/${path.join("/")}${searchParams ? `?${searchParams}` : ""}`;} export function buildFallbackTargetUrl({  defaultBackendUrl,  overrideUrl,  path,  searchParams,}: {  defaultBackendUrl: string;  overrideUrl: string | null;  path: string[];  searchParams: string;}): string | null {  return overrideUrl && defaultBackendUrl !== overrideUrl    ? buildTargetUrl(defaultBackendUrl, path, searchParams)    : null;} export function getForwardedSearchParams(request: NextRequest): {  apiKeyQuery: string | null;  searchParams: string;} {  const url = new URL(request.url);  const forwardedParams = new URLSearchParams(url.searchParams);  const apiKeyQuery = forwardedParams.get("api_key");  if (apiKeyQuery) forwardedParams.delete("api_key");  return { apiKeyQuery, searchParams: forwardedParams.toString() };} const DEFAULT_REQUEST_BODY_LIMIT = 32 * 1024 * 1024;const VOICE_REQUEST_BODY_LIMIT = 21 * 1024 * 1024;const TRANSCRIPTION_REQUEST_BODY_LIMIT = 101 * 1024 * 1024; export class ProxyBodyTooLargeError extends Error {} export const proxyRequestBodyLimit = (path: readonly string[]): number => {  const route = path.join("/");  if (route === "v1/audio/voices") return VOICE_REQUEST_BODY_LIMIT;  if (route === "v1/audio/transcriptions") return TRANSCRIPTION_REQUEST_BODY_LIMIT;  return DEFAULT_REQUEST_BODY_LIMIT;}; export const readProxyRequestBody = async (  request: Request,  method: string,  limit = DEFAULT_REQUEST_BODY_LIMIT,): Promise<ArrayBuffer | undefined> => {  if (method === "GET" || method === "DELETE" || !request.body) return undefined;  const result = await readRequestBytesWithinLimit(    request,    limit,    "Request body exceeds the proxy limit",  );  if (!result.ok) {    if (result.status === 413) throw new ProxyBodyTooLargeError(result.error);    throw new Error(result.error);  }  const body = new ArrayBuffer(result.value.byteLength);  new Uint8Array(body).set(result.value);  return body;}; export function buildProxyRequestHeaders(  request: NextRequest,  apiKey: string,  apiKeyQuery: string | null,  allowQueryApiKey: boolean,): Headers {  const headers = new Headers();  const accept = request.headers.get("accept");  const contentType = request.headers.get("content-type");  const incomingAuth = request.headers.get("authorization");  const suppressAuth = request.headers.get("x-backend-suppress-auth") === "1";  if (accept) headers.set("Accept", accept);  if (contentType) headers.set("Content-Type", contentType);  if (suppressAuth) return headers;  if (incomingAuth) headers.set("Authorization", incomingAuth);  else if (allowQueryApiKey && apiKeyQuery) headers.set("Authorization", `Bearer ${apiKeyQuery}`);  else if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`);  else if (apiKeyQuery) headers.set("Authorization", `Bearer ${apiKeyQuery}`);  return headers;} export async function fetchWithOptionalFallback(  primaryUrl: string,  fallbackUrl: string | null,  init: RequestInit,  context: {    client: ClientInfo;    method: string;    path: string[];    overrideUsed: boolean;    strictOverride: boolean;  },): Promise<{ response: Response; usedFallback: boolean }> {  const canFallback = Boolean(    context.overrideUsed && !context.strictOverride && fallbackUrl && fallbackUrl !== primaryUrl,  );   const maxConnectionAttempts = context.method === "GET" || context.method === "HEAD" ? 2 : 1;   const fetchOnce = async (url: string): Promise<Response> => {    const controller = new AbortController();    const timeoutMs = getUpstreamTimeoutMs(context.path, context.method);    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);    try {      return await fetch(url, { ...init, signal: controller.signal, redirect: "manual" });    } finally {      clearTimeout(timeoutId);    }  };   const fetchWithTimeout = async (url: string): Promise<Response> => {    let lastError: unknown;    for (let attempt = 0; attempt < maxConnectionAttempts; attempt++) {      try {        return await fetchOnce(url);      } catch (error) {        lastError = error;        if (attempt < maxConnectionAttempts - 1 && isRetriableConnectionError(error)) {          await new Promise((resolve) => setTimeout(resolve, 150));          continue;        }        throw error;      }    }    throw lastError;  };   try {    const primaryResponse = await fetchWithTimeout(primaryUrl);    if (canFallback && shouldFallbackFromResponse(primaryResponse)) {      console.warn(        `[PROXY FALLBACK] ip=${context.client.ip} | country=${context.client.country} | method=${context.method} | path=/${context.path.join("/")} | reason=override-404-text`,      );      return { response: await fetchWithTimeout(fallbackUrl as string), usedFallback: true };    }    return { response: primaryResponse, usedFallback: false };  } catch (error) {    if (!canFallback) throw error;    console.warn(      `[PROXY FALLBACK] ip=${context.client.ip} | country=${context.client.country} | method=${context.method} | path=/${context.path.join("/")} | reason=override-network-error | error=${String(error)}`,    );    return { response: await fetchWithTimeout(fallbackUrl as string), usedFallback: true };  }}