// SPDX-License-Identifier: AGPL-3.0-or-later import type {HttpErrorType} from '@pkgs/http_client/src/HttpError'; const NETWORK_ERROR_CODES = new Set([ 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', ]); const NETWORK_ERROR_MESSAGE_FRAGMENTS = [ 'ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'fetch failed', ] as const; interface NodeErrorLike { code?: unknown; cause?: unknown; } interface NodeCauseLike { code?: unknown; } interface ClassifiedRequestError { message: string; isNetworkError: boolean; errorType: HttpErrorType; } interface RequestSignalContext { signal: AbortSignal; cleanup(): void; } function isNodeErrorLike(error: unknown): error is NodeErrorLike { return typeof error === 'object' && error !== null; } function resolveErrorCode(error: unknown): string | undefined { if (!isNodeErrorLike(error)) { return undefined; } if (typeof error.code === 'string') { return error.code; } if (!isNodeErrorLike(error.cause)) { return undefined; } const cause: NodeCauseLike = error.cause; if (typeof cause.code === 'string') { return cause.code; } return undefined; } export function buildRequestHeaders( defaultHeaders: Record, requestHeaders?: Record, ): Record { if (!requestHeaders) { return {...defaultHeaders}; } return {...defaultHeaders, ...requestHeaders}; } export function resolveRequestBody(body: unknown, headers: Record): string | undefined { if (body === null || body === undefined) { return undefined; } if (body instanceof URLSearchParams) { if (!headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; } return body.toString(); } if (typeof body === 'string') { return body; } if (!headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/json'; } return JSON.stringify(body); } export function createRequestSignal(timeoutMs: number, inputSignal?: AbortSignal): RequestSignalContext { const timeoutController = new AbortController(); const combinedController = new AbortController(); const attachedListeners: Array<{ signal: AbortSignal; listener: () => void; }> = []; const timeoutId = setTimeout(() => { timeoutController.abort('Request timed out'); }, timeoutMs); function abortWithReason(reason: unknown): void { if (!combinedController.signal.aborted) { combinedController.abort(reason); } } function attachSignal(signal: AbortSignal): void { if (signal.aborted) { abortWithReason(signal.reason); return; } const listener = () => { abortWithReason(signal.reason); }; signal.addEventListener('abort', listener, {once: true}); attachedListeners.push({signal, listener}); } if (inputSignal) { attachSignal(inputSignal); } attachSignal(timeoutController.signal); function cleanup(): void { clearTimeout(timeoutId); for (const attachedListener of attachedListeners) { attachedListener.signal.removeEventListener('abort', attachedListener.listener); } } return { signal: combinedController.signal, cleanup, }; } export function classifyRequestError(error: unknown): ClassifiedRequestError { const message = error instanceof Error ? error.message : 'Request failed'; if (error instanceof Error && error.name === 'AbortError') { return { message, isNetworkError: false, errorType: 'aborted', }; } const errorCode = resolveErrorCode(error); const containsNetworkErrorMessage = NETWORK_ERROR_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment)); const isNetworkError = (errorCode !== undefined && NETWORK_ERROR_CODES.has(errorCode)) || containsNetworkErrorMessage || error instanceof TypeError; if (isNetworkError) { return { message, isNetworkError: true, errorType: 'network_error', }; } return { message, isNetworkError: false, errorType: 'unknown', }; } export function statusToMetricLabel(status: number): string { if (status >= 200 && status < 300) { return '2xx'; } return status.toString(); }