ce2ca4a689
- 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.
193 lines
5.6 KiB
TypeScript
193 lines
5.6 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;
|
||
/**
|
||
* 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 8–10 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);
|
||
}
|
||
}
|