/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {HttpStatus, REDIRECT_STATUS_CODES} from '@fluxer/constants/src/HttpConstants'; import { buildRequestHeaders, classifyRequestError, createRequestSignal, resolveRequestBody, statusToMetricLabel, } from '@fluxer/http_client/src/HttpClientRequestInternals'; import type { HttpClientMetrics, HttpClientTelemetry, HttpClientTracing, } from '@fluxer/http_client/src/HttpClientTelemetryTypes'; import type { HttpClient, HttpClientFactoryOptions, HttpMethod, RequestOptions, RequestUrlPolicy, RequestUrlValidationContext, ResponseStream, StreamResponse, } from '@fluxer/http_client/src/HttpClientTypes'; import {HttpError} from '@fluxer/http_client/src/HttpError'; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_REDIRECTS = 5; const DEFAULT_SERVICE_NAME = 'unknown'; interface ResolvedClientConfig { defaultHeaders: Record; defaultTimeoutMs: number; maxRedirects: number; requestUrlPolicy?: RequestUrlPolicy; telemetry?: HttpClientTelemetry; } function createDefaultHeaders(userAgent: string, defaultHeaders?: Record): Record { const headers: Record = { Accept: '*/*', 'User-Agent': userAgent, 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', }; if (defaultHeaders) { for (const [key, value] of Object.entries(defaultHeaders)) { headers[key] = value; } } return headers; } function resolveClientConfig( userAgentOrOptions: string | HttpClientFactoryOptions, telemetry?: HttpClientTelemetry, ): ResolvedClientConfig { if (typeof userAgentOrOptions === 'string') { return { defaultHeaders: createDefaultHeaders(userAgentOrOptions), defaultTimeoutMs: DEFAULT_TIMEOUT_MS, maxRedirects: DEFAULT_MAX_REDIRECTS, requestUrlPolicy: undefined, telemetry, }; } const maxRedirects = typeof userAgentOrOptions.maxRedirects === 'number' && userAgentOrOptions.maxRedirects >= 0 ? userAgentOrOptions.maxRedirects : DEFAULT_MAX_REDIRECTS; const defaultTimeoutMs = typeof userAgentOrOptions.defaultTimeoutMs === 'number' && userAgentOrOptions.defaultTimeoutMs > 0 ? userAgentOrOptions.defaultTimeoutMs : DEFAULT_TIMEOUT_MS; return { defaultHeaders: createDefaultHeaders(userAgentOrOptions.userAgent, userAgentOrOptions.defaultHeaders), defaultTimeoutMs, maxRedirects, requestUrlPolicy: userAgentOrOptions.requestUrlPolicy, telemetry: userAgentOrOptions.telemetry, }; } function createFetchInit( method: HttpMethod, headers: Record, body: string | undefined, signal: AbortSignal, ): RequestInit { return { method, headers, body, signal, redirect: 'manual', }; } function isRedirectStatus(status: number): boolean { return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]); } const SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']); const BODY_RELATED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding']); function shouldSwitchToGet(status: number, method: HttpMethod): boolean { if (status === HttpStatus.SEE_OTHER) { return true; } if (status === HttpStatus.MOVED_PERMANENTLY || status === HttpStatus.FOUND) { return method !== 'GET' && method !== 'HEAD'; } return false; } function buildRedirectHeaders( headers: Record, stripSensitive: boolean, dropBodyHeaders: boolean, ): Record { const nextHeaders: Record = {}; for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); if (stripSensitive && SENSITIVE_REDIRECT_HEADERS.has(lowerKey)) { continue; } if (dropBodyHeaders && BODY_RELATED_HEADERS.has(lowerKey)) { continue; } nextHeaders[key] = value; } return nextHeaders; } async function fetchWithRedirects( url: string, method: HttpMethod, headers: Record, body: string | undefined, signal: AbortSignal, maxRedirects: number, requestUrlPolicy?: RequestUrlPolicy, ): Promise { let currentUrl = new URL(url); let currentMethod: HttpMethod = method; let currentBody = body; let currentHeaders = {...headers}; await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, { phase: 'initial', redirectCount: 0, }); let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal)); let redirectCount = 0; while (isRedirectStatus(response.status)) { if (redirectCount >= maxRedirects) { throw new HttpError(`Maximum number of redirects (${maxRedirects}) exceeded`); } const location = response.headers.get('location'); if (!location) { throw new HttpError('Received redirect response without Location header', response.status); } const previousUrl = currentUrl; const nextUrl = new URL(location, response.url || currentUrl.href); const switchToGet = shouldSwitchToGet(response.status, currentMethod); if (switchToGet) { currentMethod = 'GET'; currentBody = undefined; } const previousOrigin = previousUrl.origin; const nextOrigin = nextUrl.origin; const stripSensitive = previousOrigin !== nextOrigin; currentHeaders = buildRedirectHeaders(currentHeaders, stripSensitive, switchToGet); const nextRedirectCount = redirectCount + 1; await validateRequestUrlPolicy(requestUrlPolicy, nextUrl, { phase: 'redirect', redirectCount: nextRedirectCount, previousUrl: previousUrl.href, }); currentUrl = nextUrl; response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal)); redirectCount = nextRedirectCount; } return response; } async function validateRequestUrlPolicy( requestUrlPolicy: RequestUrlPolicy | undefined, url: URL, context: RequestUrlValidationContext, ): Promise { if (!requestUrlPolicy) { return; } await requestUrlPolicy.validate(url, context); } async function runWithTracing( tracing: HttpClientTracing | undefined, method: HttpMethod, url: string, serviceName: string, fn: () => Promise, ): Promise { if (!tracing) { return fn(); } return tracing.withSpan( { name: 'http_client.fetch', attributes: { 'http.request.method': method, 'url.full': url, 'service.name': serviceName, }, }, fn, ); } function recordSuccessfulRequestMetrics( metrics: HttpClientMetrics | undefined, serviceName: string, method: HttpMethod, status: number, durationMs: number, ): void { if (!metrics) { return; } const statusCategory = statusToMetricLabel(status); metrics.histogram({ name: 'http_client.latency', dimensions: {service: serviceName, method}, valueMs: durationMs, }); metrics.counter({ name: 'http_client.request', dimensions: {service: serviceName, method, status: statusCategory}, }); metrics.counter({ name: 'http_client.response', dimensions: {service: serviceName, status_code: statusCategory}, }); } function recordHttpErrorMetrics( metrics: HttpClientMetrics | undefined, serviceName: string, method: HttpMethod, status: string, durationMs: number, ): void { if (!metrics) { return; } metrics.counter({ name: 'http_client.request', dimensions: {service: serviceName, method, status}, }); metrics.histogram({ name: 'http_client.latency', dimensions: {service: serviceName, method}, valueMs: durationMs, }); } function recordUnhandledErrorMetrics( metrics: HttpClientMetrics | undefined, serviceName: string, method: HttpMethod, status: string, errorType: string, durationMs: number, ): void { if (!metrics) { return; } metrics.counter({ name: 'http_client.request', dimensions: {service: serviceName, method, status}, }); metrics.histogram({ name: 'http_client.latency', dimensions: {service: serviceName, method, error_type: errorType}, valueMs: durationMs, }); } export function createHttpClient(userAgent: string, telemetry?: HttpClientTelemetry): HttpClient; export function createHttpClient(options: HttpClientFactoryOptions): HttpClient; export function createHttpClient( userAgentOrOptions: string | HttpClientFactoryOptions, telemetry?: HttpClientTelemetry, ): HttpClient { const config = resolveClientConfig(userAgentOrOptions, telemetry); const metrics = config.telemetry?.metrics; const tracing = config.telemetry?.tracing; async function request(opts: RequestOptions): Promise { const startTime = Date.now(); const method: HttpMethod = opts.method ?? 'GET'; const serviceName = opts.serviceName ?? DEFAULT_SERVICE_NAME; const timeoutMs = typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : config.defaultTimeoutMs; const requestSignal = createRequestSignal(timeoutMs, opts.signal); const headers = buildRequestHeaders(config.defaultHeaders, opts.headers); const body = resolveRequestBody(opts.body, headers); try { const response = await runWithTracing(tracing, method, opts.url, serviceName, async () => { return fetchWithRedirects( opts.url, method, headers, body, requestSignal.signal, config.maxRedirects, config.requestUrlPolicy, ); }); const result: StreamResponse = { stream: response.body, headers: response.headers, status: response.status, url: response.url || opts.url, }; const durationMs = Date.now() - startTime; recordSuccessfulRequestMetrics(metrics, serviceName, method, result.status, durationMs); return result; } catch (error) { const durationMs = Date.now() - startTime; if (error instanceof HttpError) { recordHttpErrorMetrics(metrics, serviceName, method, error.status?.toString() ?? 'error', durationMs); throw error; } const classifiedError = classifyRequestError(error); const status = classifiedError.isNetworkError ? 'network_error' : 'error'; recordUnhandledErrorMetrics(metrics, serviceName, method, status, classifiedError.errorType, durationMs); throw new HttpError( classifiedError.message, undefined, undefined, classifiedError.isNetworkError, classifiedError.errorType, ); } finally { requestSignal.cleanup(); } } async function sendRequest(opts: RequestOptions): Promise { return request(opts); } async function streamToString(stream: ResponseStream): Promise { if (!stream) { return ''; } return new Response(stream).text(); } return { request, sendRequest, streamToString, }; }