plan/react-rewrite #1
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ApiClient } from "./client.js";
|
||||
import { ApiHttpError, ApiTimeoutError } from "./errors.js";
|
||||
|
||||
function mockFetch(responses: Array<{ status: number; body?: unknown; headers?: Record<string, string>; delay?: number }>): typeof fetch {
|
||||
let callIndex = 0;
|
||||
return vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => {
|
||||
const resp = responses[callIndex++];
|
||||
if (!resp) throw new Error("No more mock responses");
|
||||
if (resp.delay) await new Promise((r) => setTimeout(r, resp.delay));
|
||||
return new Response(
|
||||
resp.body !== undefined ? JSON.stringify(resp.body) : null,
|
||||
{
|
||||
status: resp.status,
|
||||
headers: new Headers(resp.headers ?? {}),
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("ApiClient", () => {
|
||||
it("deserializes a successful JSON response", async () => {
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: mockFetch([{ status: 200, body: { flights: [] } }]),
|
||||
});
|
||||
const data = await client.get<{ flights: unknown[] }>("/flights");
|
||||
expect(data).toEqual({ flights: [] });
|
||||
});
|
||||
|
||||
it("appends query params and locale header", async () => {
|
||||
const fetchSpy = mockFetch([{ status: 200, body: {} }]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "en",
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
await client.get("/flights", { date: "2025-01-15", dep: "SVO" });
|
||||
const calledUrl = (fetchSpy as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as string;
|
||||
expect(calledUrl).toContain("/flights?");
|
||||
expect(calledUrl).toContain("date=2025-01-15");
|
||||
expect(calledUrl).toContain("dep=SVO");
|
||||
});
|
||||
|
||||
it("throws ApiHttpError on 404 without retrying", async () => {
|
||||
const fetchSpy = mockFetch([{ status: 404, body: { error: "not found" } }]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: fetchSpy,
|
||||
retry: { maxRetries: 3 },
|
||||
});
|
||||
await expect(client.get("/flights/999")).rejects.toThrow(ApiHttpError);
|
||||
expect((fetchSpy as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries on 500 up to maxRetries then throws ApiHttpError", async () => {
|
||||
const fetchSpy = mockFetch([
|
||||
{ status: 500 },
|
||||
{ status: 500 },
|
||||
{ status: 500 },
|
||||
{ status: 500 },
|
||||
]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: fetchSpy,
|
||||
retry: { maxRetries: 3, timeoutFactor: 0 },
|
||||
});
|
||||
await expect(client.get("/flights")).rejects.toThrow(ApiHttpError);
|
||||
// 1 initial + 3 retries = 4 total
|
||||
expect((fetchSpy as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("retries on 429 and succeeds on retry", async () => {
|
||||
const fetchSpy = mockFetch([
|
||||
{ status: 429, headers: { "Retry-After": "0" } },
|
||||
{ status: 200, body: { ok: true } },
|
||||
]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: fetchSpy,
|
||||
retry: { maxRetries: 3, timeoutFactor: 0 },
|
||||
});
|
||||
const data = await client.get<{ ok: boolean }>("/flights");
|
||||
expect(data.ok).toBe(true);
|
||||
expect((fetchSpy as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not retry on 400", async () => {
|
||||
const fetchSpy = mockFetch([{ status: 400, body: { error: "bad request" } }]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: fetchSpy,
|
||||
retry: { maxRetries: 3 },
|
||||
});
|
||||
await expect(client.get("/flights")).rejects.toThrow(ApiHttpError);
|
||||
expect((fetchSpy as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws ApiTimeoutError on timeout", async () => {
|
||||
const neverResolve = vi.fn((_url: string | URL | Request, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted.", "AbortError"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: neverResolve as unknown as typeof fetch,
|
||||
defaultTimeoutMs: 10,
|
||||
retry: { maxRetries: 0 },
|
||||
});
|
||||
await expect(client.get("/flights")).rejects.toThrow(ApiTimeoutError);
|
||||
});
|
||||
|
||||
it("post() sends JSON body", async () => {
|
||||
const fetchSpy = mockFetch([{ status: 201, body: { id: 1 } }]);
|
||||
const client = new ApiClient({
|
||||
baseUrl: "https://api.example",
|
||||
locale: "ru",
|
||||
fetchImpl: fetchSpy,
|
||||
});
|
||||
const data = await client.post<{ id: number }>("/bookings", { flight: "SU100" });
|
||||
expect(data.id).toBe(1);
|
||||
const callInit = (fetchSpy as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as RequestInit;
|
||||
expect(callInit.method).toBe("POST");
|
||||
expect(callInit.body).toBe(JSON.stringify({ flight: "SU100" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user