import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import { sdkLogger as logger } from "../services/reporting-service/logger.js"; export class HTTPError extends Error { constructor( message: string, public status: number, public code?: string, public data?: unknown ) { super(message); this.name = "HTTPError"; } } export type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export interface HTTPRequestConfig { method: HTTPMethod; url: string; headers?: Record; data?: unknown; params?: Record; } export interface HTTPResponse { data: T; status: number; headers: Record; } export interface HTTPClient { request(config: HTTPRequestConfig): Promise>; handleError(error: unknown, requestConfig?: HTTPRequestConfig): HTTPError; destroy(): Promise; } export interface HTTPClientConfig { baseUrl: string; retryAttempts: number; /** * Request timeout in milliseconds. * * Axios interprets `0` as "no timeout". * If omitted, defaults to 30 seconds. */ timeoutMs?: number; } const SLOW_REQUEST_MS = 2000; const BODY_PREVIEW_CHARS = 1024; const DEFAULT_HTTP_TIMEOUT_MS = 30_000; function normalizeTimeoutMs(timeoutMs: number | undefined): number { if (timeoutMs === undefined) return DEFAULT_HTTP_TIMEOUT_MS; if (!Number.isFinite(timeoutMs)) return DEFAULT_HTTP_TIMEOUT_MS; // Allow 0 (axios: no timeout). Negative doesn't make sense. if (timeoutMs < 0) return DEFAULT_HTTP_TIMEOUT_MS; return timeoutMs; } function truncate(str: string, maxChars: number): string { if (str.length <= maxChars) return str; return `${str.slice(0, maxChars)}…[truncated]`; } function safeSerialize(value: unknown): string { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } function getHeader(headers: Record | undefined, key: string): string | undefined { if (!headers) return undefined; const target = key.toLowerCase(); for (const [k, v] of Object.entries(headers)) { if (k.toLowerCase() === target) return v; } return undefined; } function hasAuthHeader(headers: Record | undefined): boolean { return typeof getHeader(headers, "authorization") === "string"; } function setHeaderIfMissing( headers: Record, key: string, value: string ): void { const target = key.toLowerCase(); for (const existingKey of Object.keys(headers)) { if (existingKey.toLowerCase() === target) return; } headers[key] = value; } function ensureJsonContentType( headers: Record | undefined, data: unknown ): Record | undefined { if (data == null) return headers; const out: Record = { ...(headers || {}) }; // If we're sending a body and caller didn't specify a content-type, force JSON. setHeaderIfMissing(out, "Content-Type", "application/json"); return out; } function getBodyLength(value: unknown): number | undefined { if (value == null) return undefined; if (typeof value === "string") return value.length; try { return JSON.stringify(value).length; } catch { return undefined; } } function previewBody(value: unknown): string | undefined { if (value == null) return undefined; const s = safeSerialize(value); return truncate(s, BODY_PREVIEW_CHARS); } function summarizeAxiosError(error: unknown): Record { const message = error instanceof Error ? error.message : String(error); if (!(error instanceof AxiosError)) { return { message }; } const data = error.response?.data as unknown; const topLevelErrors = data && typeof data === "object" && "errors" in (data as any) ? (data as any).errors : undefined; const metaErrors = data && typeof data === "object" && "meta" in (data as any) && (data as any).meta && typeof (data as any).meta === "object" && "errors" in (data as any).meta ? (data as any).meta.errors : undefined; const topLevelErrorsArray = Array.isArray(topLevelErrors) ? topLevelErrors : undefined; const metaErrorsArray = Array.isArray(metaErrors) ? metaErrors : undefined; // Prefer the "real" top-level errors array when it has content. // If it's missing or empty (current API behavior), fall back to meta.errors for useful diagnostics. const preferredErrors = topLevelErrorsArray && topLevelErrorsArray.length > 0 ? { from: "errors", value: topLevelErrorsArray } : metaErrorsArray && metaErrorsArray.length > 0 ? { from: "meta.errors", value: metaErrorsArray } : undefined; return { message, code: error.code, status: error.response?.status, errorsPreview: preferredErrors ? truncate(safeSerialize(preferredErrors.value), BODY_PREVIEW_CHARS) : undefined, errorsFrom: preferredErrors?.from, responseBodyPreview: data == null || (preferredErrors != null) ? undefined : truncate(safeSerialize(data), BODY_PREVIEW_CHARS), }; } /** * This is a factory class for creating HTTPClient instances, at least for now * just a wrapper around AxiosClient */ export class HTTPClientFactory { static getInstance(config: HTTPClientConfig): HTTPClient { return new AxiosClient( config.baseUrl, config.retryAttempts, normalizeTimeoutMs(config.timeoutMs) ); } } class AxiosClient implements HTTPClient { private client: AxiosInstance | null = null; private retryAttempts: number; private isDestroyed: boolean = false; private inFlightControllers: Set = new Set(); private pendingTimeouts: Set> = new Set(); private pendingSleepResolvers: Set<() => void> = new Set(); /** * Note regarding baseURL, from https://github.com/axios/axios * `baseURL` will be prepended to `url` unless `url` is absolute and option `allowAbsoluteUrls` is set to true (the default). * It can be convenient to set `baseURL` for an instance of axios to pass relative URLs to methods of that instance. */ constructor(baseUrl: string, retryAttempts: number, timeoutMs: number) { this.client = axios.create({ baseURL: baseUrl, timeout: timeoutMs, headers: { "Content-Type": "application/json", }, }); this.retryAttempts = retryAttempts; } private sleep(ms: number): Promise { return new Promise((resolve) => { if (this.isDestroyed) { resolve(); return; } const timeout = setTimeout(() => { this.pendingTimeouts.delete(timeout); this.pendingSleepResolvers.delete(resolve); resolve(); }, ms); this.pendingTimeouts.add(timeout); this.pendingSleepResolvers.add(resolve); }); } async request( config: HTTPRequestConfig ): Promise> { if (this.isDestroyed || !this.client) { throw new HTTPError("HTTP client has been destroyed", 500); } const startedAt = Date.now(); const controller = new AbortController(); this.inFlightControllers.add(controller); const requestHeaders = ensureJsonContentType(config.headers, config.data); const axiosConfig: AxiosRequestConfig = { method: config.method, url: config.url, headers: requestHeaders, data: config.data, params: config.params, signal: controller.signal, }; const baseURL = this.client.defaults.baseURL || ""; const fullUrl = `${baseURL}${config.url}`; logger.silly("HTTP request starting", { method: config.method, url: config.url, baseURL, fullUrl, retryAttempts: this.retryAttempts, }); let lastError: unknown; let terminalLogged = false; try { for (let attempt = 0; attempt <= this.retryAttempts; attempt++) { try { logger.silly(`HTTP request attempt ${attempt + 1}/${this.retryAttempts + 1}`, { method: config.method, fullUrl, }); const response = await this.client.request(axiosConfig); const elapsedMs = Date.now() - startedAt; if (elapsedMs >= SLOW_REQUEST_MS) { logger.info("HTTP request slow", { method: config.method, fullUrl, status: response.status, elapsedMs, }); } else { logger.silly("HTTP request succeeded", { method: config.method, fullUrl, status: response.status, elapsedMs, }); } return { data: response.data, status: response.status, headers: response.headers as Record, }; } catch (error) { lastError = error; const isAxiosErr = error instanceof AxiosError; const status = isAxiosErr ? error.response?.status : undefined; const code = isAxiosErr ? error.code : undefined; const elapsedMs = Date.now() - startedAt; logger.debug(`HTTP request attempt ${attempt + 1} failed`, { method: config.method, fullUrl, attempt: attempt + 1, maxAttempts: this.retryAttempts + 1, elapsedMs, ...summarizeAxiosError(error), }); // Don't retry on 4xx errors (client errors) if ( error instanceof AxiosError && error.response?.status && error.response.status >= 400 && error.response.status < 500 ) { const isAuthError = status === 401 || status === 403; logger.warn( isAuthError ? "HTTP request not retried (auth error)" : "HTTP request not retried (client error)", { method: config.method, fullUrl, status, code, elapsedMs, ...(isAuthError ? {} : { request: { hasAuthHeader: hasAuthHeader(requestHeaders), contentType: getHeader(requestHeaders, "content-type"), bodyLength: getBodyLength(config.data), bodyPreview: previewBody(config.data), }, }), error: summarizeAxiosError(error), } ); terminalLogged = true; break; } // Don't retry canceled/aborted requests if (error instanceof AxiosError && error.code === "ERR_CANCELED") { logger.warn("HTTP request not retried (canceled)", { method: config.method, fullUrl, code: error.code, status, elapsedMs, }); terminalLogged = true; break; } // If this was the last attempt, don't wait if (attempt < this.retryAttempts) { const waitTime = Math.pow(2, attempt) * 1000; logger.silly(`Waiting ${waitTime}ms before retry`, { method: config.method, fullUrl, waitTime, }); await this.sleep(waitTime); if (this.isDestroyed) { throw new HTTPError("HTTP client has been destroyed", 500); } } } } } finally { this.inFlightControllers.delete(controller); } if (!terminalLogged) { const elapsedMs = Date.now() - startedAt; const terminalStatus = lastError instanceof AxiosError ? lastError.response?.status : undefined; const isAuthTerminal = terminalStatus === 401 || terminalStatus === 403; logger.error("HTTP request failed after retries", { method: config.method, url: config.url, baseURL, fullUrl, attemptsUsed: this.retryAttempts + 1, retryAttempts: this.retryAttempts, elapsedMs, ...(isAuthTerminal ? {} : { request: { hasAuthHeader: hasAuthHeader(requestHeaders), contentType: getHeader(requestHeaders, "content-type"), bodyLength: getBodyLength(config.data), bodyPreview: previewBody(config.data), }, }), error: summarizeAxiosError(lastError), }); } throw this.handleError(lastError, config); } handleError(error: unknown, requestConfig?: HTTPRequestConfig): HTTPError { if (error instanceof AxiosError) { // Build a more descriptive error message that includes the URL and method const baseUrl = this.client?.defaults.baseURL || ""; const fullUrl = requestConfig ? `${baseUrl}${requestConfig.url}` : "Unknown URL"; const method = requestConfig?.method || "Unknown method"; const errorMessage = `${error.message} (${method} ${fullUrl})`; return new HTTPError( errorMessage, error.response?.status || 500, error.code, error.response?.data ); } return new HTTPError( error instanceof Error ? error.message : "Unknown error occurred", 500 ); } async destroy(): Promise { if (this.isDestroyed) return; this.isDestroyed = true; // Abort any in-flight requests for (const c of this.inFlightControllers) { try { c.abort(); } catch { /* ignore: abort may throw in some environments */ } } this.inFlightControllers.clear(); // Cancel any pending retry waits for (const t of this.pendingTimeouts) { clearTimeout(t); } this.pendingTimeouts.clear(); for (const resolve of this.pendingSleepResolvers) { try { resolve(); } catch { /* ignore: resolver threw; cleanup proceeds */ } } this.pendingSleepResolvers.clear(); // Drop axios instance reference this.client = null; } }