183 lines
5.0 KiB
TypeScript
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);
|
|
}
|
|
}
|