Files
flights_web/src/shared/api/client.ts
T

183 lines
5.0 KiB
TypeScript

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;
private readonly 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<number>;
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;
this.timeoutMs = options.defaultTimeoutMs ?? 5000;
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<T>(
path: string,
query?: Record<string, string | number | boolean>,
): Promise<T> {
const url = this.buildUrl(path, query);
return this.executeWithRetry<T>(url, { method: "GET" });
}
async post<T>(path: string, body: unknown): Promise<T> {
const url = this.buildUrl(path);
return this.executeWithRetry<T>(url, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
private buildUrl(
path: string,
query?: Record<string, string | number | boolean>,
): 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<T>(
url: string,
init: RequestInit,
): Promise<T> {
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<Response> {
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);
}
}