import { Axios, isAxiosError } from 'axios'; import { HttpClientError, HttpNetworkError } from './HttpClientError'; import { Logger, LogLevel } from './Logger'; import { safeToJson } from './json'; export function addErrorHandlingInterceptor(client: Axios): void { client.interceptors.response.use( (response) => response, // Pass through successful responses (error) => { if (!isAxiosError(error)) { throw error; } if (error.response) { // HTTP error (4xx/5xx) — build a clean error with status and response data throw new HttpClientError( error.response.status, error.config?.url ?? 'unknown', error.config?.method ?? 'unknown', error.response.data, error.message, ); } // Network/timeout error (ETIMEDOUT, ECONNREFUSED, etc.) — no response available. // Wrap in a clean error to strip the bloated axios internals (agent socket pools, // TLS session caches) that produce enormous serialized log output. throw new HttpNetworkError( error.code ?? 'UNKNOWN', error.config?.url ?? 'unknown', error.config?.method ?? 'unknown', error.message || error.code || 'Network error', ); }, ); } export function addHttpRequestLoggingInterceptor( client: Axios, logger: Logger, requestLogLevel: LogLevel, prefix?: string, ): void { if (requestLogLevel === LogLevel.NONE) { return; } client.interceptors.request.use((cfg) => { const baseUrl = cfg.baseURL || 'http://unknown.com'; const path = cfg.url || ''; if (!cfg.method || cfg.method === 'get') { logHttpGetRequest(logger, prefix, requestLogLevel, baseUrl, path, cfg.params); } else if (cfg.method === 'post') { logHttpPostRequest(logger, prefix, requestLogLevel, baseUrl, path, cfg.data); } return cfg; }); } export type ResponseLogConfig = { responseLogLevel?: LogLevel; responseErrorLogLevel?: LogLevel; responseBodyLogLevel?: LogLevel; responseErrorBodyLogLevel?: LogLevel; }; export function addHttpResponseLoggingInterceptor( client: Axios, logger: Logger, { responseLogLevel = LogLevel.NONE, responseErrorLogLevel = LogLevel.NONE, responseBodyLogLevel = LogLevel.NONE, responseErrorBodyLogLevel = LogLevel.NONE, }: ResponseLogConfig, prefix?: string, ): void { const shouldLogMeta = responseLogLevel !== LogLevel.NONE; const shouldLogErrorMeta = responseErrorLogLevel !== LogLevel.NONE; const shouldLogBody = responseBodyLogLevel !== LogLevel.NONE; const shouldLogErrorBody = responseErrorBodyLogLevel !== LogLevel.NONE; if (!shouldLogMeta && !shouldLogBody && !shouldLogErrorMeta && !shouldLogErrorBody) { return; } client.interceptors.response.use( (resp) => { if (!shouldLogMeta && !shouldLogBody) { return resp; } const cfg = resp.config; const status = resp.status ?? 'NO_STATUS'; const method = cfg.method?.toUpperCase() ?? 'NO_METHOD'; const url = cfg.url ?? 'NO_URL'; const data = resp.data; const traceId = (data as any)?.traceId; const responseStr = `${!prefix ? `` : `${prefix} `}` + `HTTP Response: ${traceId ? `traceId: ${traceId} ` : ''}` + `${status} ${method} ${url}`; if (shouldLogMeta) { logger[responseLogLevel](responseStr); } if (shouldLogBody) { logger[responseBodyLogLevel](`${responseStr} - ${safeToJson(data)}`); } return resp; }, (error) => { if (!error) { return Promise.reject(error); } if (!shouldLogErrorMeta && !shouldLogErrorBody) { return Promise.reject(error); } // Non-2xx responses land here because validateStatus rejects them const resp = error.response; const cfg = error.config; const status = resp?.status ?? 'NO_STATUS'; const method = cfg?.method?.toUpperCase() ?? 'NO_METHOD'; const url = cfg?.url ?? 'NO_URL'; const data = resp?.data; const traceId = (data as any)?.traceId; const responseStr = `${!prefix ? `` : `${prefix} `}` + `HTTP Error Response: ${traceId ? `traceId: ${traceId} ` : ''}` + `${status} ${method} ${url}`; if (shouldLogErrorMeta) { logger[responseErrorLogLevel](responseStr); } if (shouldLogErrorBody) { logger[responseErrorBodyLogLevel](`${responseStr} - ${safeToJson(data)}`); } return Promise.reject(error); }, ); } function logHttpGetRequest( logger: Logger, prefix: string | undefined, level: LogLevel, baseUrl: string, path: string, params: any = {}, ): void { // Only used for compiling if (level === LogLevel.NONE) { return; } const url = getHttpUrl(baseUrl, path); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, Array.isArray(value) ? value.join(',') : String(value)); } }); const logMessage = `${!prefix ? `` : `${prefix} `}HTTP Request: GET ${url.toString()}`; logger[level](logMessage); } function logHttpPostRequest( logger: Logger, prefix: string | undefined, level: LogLevel, baseUrl: string, path: string, data: any = {}, ): void { // Only used for compiling if (level === LogLevel.NONE) { return; } const url = getHttpUrl(baseUrl, path); const logMessage = `${!prefix ? `` : `${prefix} `}HTTP Request: POST ${url.toString()}`; logger[level](logMessage, data); } function getHttpUrl(base: string, path: string): URL { // remove leading slash if present const safePath = path.startsWith('/') ? path.slice(1) : path; // add trailing slash if not present const cleanedBaseUrl = base.endsWith('/') ? base : `${base}/`; return new URL(safePath, cleanedBaseUrl); } // Callback types for request lifecycle hooks export type BeforeRequestCallback = () => Promise | void; export type RequestErrorContext = { status?: number; // HTTP status code (undefined for network errors) code?: string; // Axios error code (e.g., 'ERR_NETWORK', 'ECONNABORTED') isRateLimitError: boolean; // True if error is likely due to rate limiting error: unknown; }; export type OnRequestErrorCallback = (context: RequestErrorContext) => void; export function addBeforeRequestInterceptor(client: Axios, beforeRequest?: BeforeRequestCallback): void { if (!beforeRequest) { return; } client.interceptors.request.use(async (config) => { await beforeRequest(); return config; }); } export function addRequestErrorInterceptor(client: Axios, onRequestError?: OnRequestErrorCallback): void { if (!onRequestError) { return; } client.interceptors.response.use( (response) => response, (error) => { // Handle raw axios errors, HttpClientError (HTTP errors), and HttpNetworkError (network/timeout errors) let status: number | undefined; let code: string | undefined; if (isAxiosError(error)) { status = error.response?.status; code = error.code; } else if (error instanceof HttpNetworkError) { code = error.code; } else if (error instanceof HttpClientError) { status = error.status; } // 429 is explicit rate limit, network errors may indicate edge/CDN rate limiting const isRateLimitError = status === 429 || code === 'ERR_NETWORK' || code === 'ECONNREFUSED' || code === 'ECONNRESET'; onRequestError({ status, code, isRateLimitError, error }); throw error; }, ); }