plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
2 changed files with 317 additions and 0 deletions
Showing only changes of commit 65c8c8b55f - Show all commits
+135
View File
@@ -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" }));
});
});
+182
View File
@@ -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);
}
}