Files
flights_web/src/shared/api/client.ts
T
gnezim ce2ca4a689 i18n: BCP-47 URL locales + complete EN translations
- URL surface now matches Angular: `/ru-ru/`, `/en-us/`, `/zh-cn/`, …
  (BCP-47). Bare short codes still work — the [lang]/layout auto-
  promotes them with a replace navigation. Internally everything that
  needs the short language (i18n file lookup, API path segment,
  Accept-Language header, dictionary `title[lang]` key, Intl
  formatters) reads it through the new `useLocale()` hook, which
  returns both `locale` (BCP-47) and `language` (short).
- ApiClient.locale is now mutable and is updated from the [lang]
  layout whenever the URL locale changes — was hard-coded to "ru" in
  the root layout before, so backend responses for /en/... still came
  back in Russian. Cities / airports / flight statuses now arrive in
  the active language.
- All 21 empty EN translation keys filled in (AIRPLANE.*, BOARD.
  PREVIOUS-FLIGHT, SCHEDULE.FILE-NAME, SEO.SCHEDULE.*, SEO.FLIGHTS-
  MAP.*, SHARED.FLIGHT-TRANSFER-PLURAL-*, SHARED.WEEK_FORMAT-WRONG)
  so /en-us renders without falling back to raw keys.
- Added BOARD.LOAD-FAILED-TITLE / -MESSAGE keys (RU + EN) and removed
  the three hardcoded Russian error strings from the search-page
  error card.
- FlightStatus now reads `FLIGHT-STATUSES.{Status}` from i18n instead
  of hardcoding the Russian labels.
- FlightCard's OperatorLogo now picks the en/ru carrier-logo variant
  from `useLocale().language` instead of always passing "ru" — the
  Aeroflot/Rossiya logos display in the active language where
  variants exist.
- registerPrimeLocales(): all 9 supported languages get a PrimeReact
  `addLocale` entry at module load (RU + EN hand-curated, others built
  from Intl). Calendar/AutoComplete widgets switch with the URL.
- ErrorBoundary catches outside the i18n provider, so it now ships
  its own minimal localised string table keyed off the URL locale —
  no more "Something went wrong" leaking on the Russian site.
- Hreflang URLs now emit BCP-47 (`/en-us/...`) while `hreflang="en"`
  stays the short Google-friendly form.
- Datetime helpers accept either short or BCP-47 locale (`isRussianLocale`)
  so callers can pass through whatever the route hands them.
2026-04-19 17:36:24 +03:00

193 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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.bind(globalThis);
// Upstream schedule endpoint regularly returns 7MB+ payloads over
// a slow WAF-protected connection and can take 810 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<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);
}
}