/** * HTTP transport primitives for ComplianceClient. * * Phase 4: extracted from client.ts. Handles headers, timeouts, retry logic, * abort-controller lifecycle, and error classification so the main client * module can focus on domain endpoints. */ export interface HttpTransportOptions { apiEndpoint: string tenantId: string timeout?: number maxRetries?: number onAuthError?: () => void onError?: (error: Error) => void } export interface APIError extends Error { status?: number code?: string retryable: boolean } export const DEFAULT_TIMEOUT = 30000 export const DEFAULT_MAX_RETRIES = 3 const RETRY_DELAYS = [1000, 2000, 4000] export function createHttpError( message: string, status?: number, retryable = false ): APIError { const error = new Error(message) as APIError error.status = status error.retryable = retryable return error } const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) /** * HttpTransport — reusable fetch wrapper with retry + timeout + abort control. * * Not exposed via the SDK public surface; ComplianceClient instantiates one * internally. */ export class HttpTransport { readonly apiEndpoint: string private apiKey: string | null = null private accessToken: string | null = null private tenantId: string private timeout: number private maxRetries: number private abortControllers: Map = new Map() private onAuthError?: () => void private onError?: (error: Error) => void constructor(options: HttpTransportOptions) { this.apiEndpoint = options.apiEndpoint.replace(/\/$/, '') this.tenantId = options.tenantId this.timeout = options.timeout ?? DEFAULT_TIMEOUT this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES this.onAuthError = options.onAuthError this.onError = options.onError } setApiKey(apiKey: string | null): void { this.apiKey = apiKey } setAccessToken(token: string | null): void { this.accessToken = token } setTenantId(tenantId: string): void { this.tenantId = tenantId } getTenantId(): string { return this.tenantId } getHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', 'X-Tenant-ID': this.tenantId, } if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}` } else if (this.apiKey) { headers['Authorization'] = `Bearer ${this.apiKey}` } return headers } async fetchWithTimeout( url: string, options: RequestInit, requestId: string ): Promise { const controller = new AbortController() this.abortControllers.set(requestId, controller) const timeoutId = setTimeout(() => controller.abort(), this.timeout) try { const response = await fetch(url, { ...options, signal: controller.signal, }) return response } finally { clearTimeout(timeoutId) this.abortControllers.delete(requestId) } } async fetchWithRetry( url: string, options: RequestInit, retries = this.maxRetries ): Promise { const requestId = `${Date.now()}-${Math.random()}` let lastError: Error | null = null for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await this.fetchWithTimeout(url, options, requestId) if (!response.ok) { const errorBody = await response.text() let errorMessage = `HTTP ${response.status}` try { const errorJson = JSON.parse(errorBody) errorMessage = errorJson.error || errorJson.message || errorMessage } catch { // Keep HTTP status message } // Handle auth errors if (response.status === 401) { this.onAuthError?.() throw createHttpError(errorMessage, response.status, false) } // Don't retry client errors (4xx) except 429 const retryable = response.status >= 500 || response.status === 429 if (!retryable || attempt === retries) { throw createHttpError(errorMessage, response.status, retryable) } } else { const data = await response.json() return data as T } } catch (error) { lastError = error as Error if (error instanceof Error && error.name === 'AbortError') { throw createHttpError('Request timeout', 408, true) } const apiError = error as APIError if (!apiError.retryable || attempt === retries) { this.onError?.(error as Error) throw error } } // Exponential backoff if (attempt < retries) { await sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1]) } } throw lastError || createHttpError('Unknown error', 500, false) } cancelAllRequests(): void { this.abortControllers.forEach(controller => controller.abort()) this.abortControllers.clear() } }