import type { Language } from "@/i18n/resolver"; import type { Logger } from "@/observability/logger/types"; import { ApiHttpError, ApiTimeoutError, ApiNetworkError } from "./errors.js"; export interface ApiClientRetryOptions { maxRetries?: number; timeoutFactor?: number; statusCodes?: number[]; } export interface ApiClientOptions { baseUrl: string; locale: Language; traceId?: string; fetchImpl?: typeof fetch; defaultTimeoutMs?: number; retry?: ApiClientRetryOptions; logger?: Logger; } const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504]; export class ApiClient { private readonly baseUrl: string; /** * Mutable so the surrounding layout can update it on locale change * without rebuilding the whole client (and dropping any in-flight * SignalR connections held alongside it). All API calls read this * field at request time. */ locale: Language; private readonly traceId: string | undefined; private readonly fetchFn: typeof fetch; private readonly timeoutMs: number; private readonly maxRetries: number; private readonly timeoutFactor: number; private readonly retryStatusCodes: Set; private readonly logger: Logger | undefined; constructor(options: ApiClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); this.locale = options.locale; this.traceId = options.traceId; this.fetchFn = options.fetchImpl ?? globalThis.fetch.bind(globalThis); // Upstream schedule endpoint regularly returns 7MB+ payloads over // a slow WAF-protected connection and can take 8–10 seconds in // dev. 5s was far too aggressive — the fetch would abort mid-body // and cascade into retry loops. 30s matches Angular's default. this.timeoutMs = options.defaultTimeoutMs ?? 30_000; this.maxRetries = options.retry?.maxRetries ?? 3; this.timeoutFactor = options.retry?.timeoutFactor ?? 2; this.retryStatusCodes = new Set( options.retry?.statusCodes ?? DEFAULT_RETRY_STATUS_CODES, ); this.logger = options.logger; } async get( path: string, query?: Record, ): Promise { const url = this.buildUrl(path, query); return this.executeWithRetry(url, { method: "GET" }); } async post(path: string, body: unknown): Promise { const url = this.buildUrl(path); return this.executeWithRetry(url, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }); } private buildUrl( path: string, query?: Record, ): string { const base = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`; if (!query || Object.keys(query).length === 0) return base; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { params.set(key, String(value)); } return `${base}?${params.toString()}`; } private async executeWithRetry( url: string, init: RequestInit, ): Promise { const headers = new Headers(init.headers ?? {}); headers.set("Accept-Language", this.locale); if (this.traceId) { headers.set("X-Trace-Id", this.traceId); } let lastError: Error | undefined; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { if (attempt > 0) { const delay = this.calculateDelay(attempt); if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } } try { const response = await this.fetchWithTimeout(url, { ...init, headers, }); if (response.ok) { return (await response.json()) as T; } const status = response.status; let body: unknown; try { body = await response.json(); } catch { // body may not be JSON } const httpError = new ApiHttpError( `HTTP ${status} from ${url}`, status, body, ); if (this.retryStatusCodes.has(status) && attempt < this.maxRetries) { lastError = httpError; this.logger?.warn("Retrying request", { url, status, attempt: attempt + 1, }); continue; } throw httpError; } catch (err) { if (err instanceof ApiHttpError || err instanceof ApiTimeoutError) { if (err instanceof ApiTimeoutError && attempt < this.maxRetries) { lastError = err; continue; } throw err; } if (err instanceof Error) { if (attempt < this.maxRetries) { lastError = new ApiNetworkError(err); continue; } throw new ApiNetworkError(err); } throw err; } } throw lastError ?? new Error("Unexpected retry exhaustion"); } private async fetchWithTimeout( url: string, init: RequestInit, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); try { return await this.fetchFn(url, { ...init, signal: controller.signal, }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw new ApiTimeoutError(this.timeoutMs); } throw err; } finally { clearTimeout(timeoutId); } } private calculateDelay(attempt: number): number { if (this.timeoutFactor === 0) return 0; return Math.min(1000 * this.timeoutFactor ** (attempt - 1), 30000); } }