import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, } from "axios"; import { getVersion } from "./getVersion"; export interface HttpClientOptions { apiKey: string; apiUrl: string; timeoutMs?: number; maxRetries?: number; backoffFactor?: number; // seconds factor for 0.5, 1, 2... } export interface RequestOptions { headers?: Record; timeoutMs?: number; } export class HttpClient { private instance: AxiosInstance; private readonly apiKey: string; private readonly apiUrl: string; private readonly maxRetries: number; private readonly backoffFactor: number; constructor(options: HttpClientOptions) { this.apiKey = options.apiKey; this.apiUrl = options.apiUrl.replace(/\/$/, ""); this.maxRetries = options.maxRetries ?? 3; this.backoffFactor = options.backoffFactor ?? 0.5; this.instance = axios.create({ baseURL: this.apiUrl, timeout: options.timeoutMs ?? 300000, headers: { Authorization: `Bearer ${this.apiKey}`, }, transitional: { clarifyTimeoutError: true }, }); } getApiUrl(): string { return this.apiUrl; } getApiKey(): string { return this.apiKey; } private async request( config: AxiosRequestConfig, ): Promise> { const version = getVersion(); config.headers = { ...(config.headers || {}), }; let lastError: any; for (let attempt = 0; attempt < this.maxRetries; attempt++) { try { const cfg: AxiosRequestConfig = { ...config }; const isFormDataBody = typeof FormData !== "undefined" && cfg.data instanceof FormData; const isPlainObjectBody = !isFormDataBody && cfg.data != null && typeof cfg.data === "object" && !Array.isArray(cfg.data); // For JSON POST/PUT/PATCH, ensure origin is present in body if ( isPlainObjectBody && cfg.method && ["post", "put", "patch"].includes(cfg.method.toLowerCase()) ) { const data = (cfg.data ?? {}) as Record; cfg.data = { ...data, origin: typeof data.origin === "string" && data.origin.includes("mcp") ? data.origin : `js-sdk@${version}`, }; } if (isFormDataBody) { cfg.headers = { ...(cfg.headers || {}) }; delete (cfg.headers as Record)["Content-Type"]; delete (cfg.headers as Record)["content-type"]; } const res = await this.instance.request(cfg); if (res.status === 502 && attempt < this.maxRetries - 1) { await this.sleep(this.backoffFactor * Math.pow(2, attempt)); continue; } return res; } catch (err: any) { lastError = err; const status = err?.response?.status; if (status === 502 && attempt < this.maxRetries - 1) { await this.sleep(this.backoffFactor * Math.pow(2, attempt)); continue; } throw err; } } throw lastError ?? new Error("Unexpected HTTP client error"); } private sleep(seconds: number): Promise { return new Promise(r => setTimeout(r, seconds * 1000)); } post( endpoint: string, body: Record, options?: RequestOptions, ) { return this.request({ method: "post", url: endpoint, data: body, headers: options?.headers, timeout: options?.timeoutMs, }); } postMultipart( endpoint: string, formData: FormData, options?: RequestOptions, ) { return this.request({ method: "post", url: endpoint, data: formData, headers: options?.headers, timeout: options?.timeoutMs, }); } get(endpoint: string, headers?: Record) { return this.request({ method: "get", url: endpoint, headers }); } delete(endpoint: string, headers?: Record) { return this.request({ method: "delete", url: endpoint, headers }); } patch( endpoint: string, body: Record, options?: RequestOptions, ) { return this.request({ method: "patch", url: endpoint, data: body, headers: options?.headers, timeout: options?.timeoutMs, }); } prepareHeaders(idempotencyKey?: string): Record { const headers: Record = {}; if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey; return headers; } }