refactor progress
This commit is contained in:
407
packages/http_client/src/HttpClient.tsx
Normal file
407
packages/http_client/src/HttpClient.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, string>;
|
||||
defaultTimeoutMs: number;
|
||||
maxRedirects: number;
|
||||
requestUrlPolicy?: RequestUrlPolicy;
|
||||
telemetry?: HttpClientTelemetry;
|
||||
}
|
||||
|
||||
function createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, string>,
|
||||
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<string, string>,
|
||||
stripSensitive: boolean,
|
||||
dropBodyHeaders: boolean,
|
||||
): Record<string, string> {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
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<string, string>,
|
||||
body: string | undefined,
|
||||
signal: AbortSignal,
|
||||
maxRedirects: number,
|
||||
requestUrlPolicy?: RequestUrlPolicy,
|
||||
): Promise<Response> {
|
||||
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<void> {
|
||||
if (!requestUrlPolicy) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestUrlPolicy.validate(url, context);
|
||||
}
|
||||
|
||||
async function runWithTracing<T>(
|
||||
tracing: HttpClientTracing | undefined,
|
||||
method: HttpMethod,
|
||||
url: string,
|
||||
serviceName: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<StreamResponse> {
|
||||
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<StreamResponse> {
|
||||
return request(opts);
|
||||
}
|
||||
|
||||
async function streamToString(stream: ResponseStream): Promise<string> {
|
||||
if (!stream) {
|
||||
return '';
|
||||
}
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
sendRequest,
|
||||
streamToString,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user