Files
frontend/src/shared/api/client.ts
T
codex-bot 5077bd4f0b
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
refactor: drop dead query helpers and simplify read deadline (WealthLedger/frontend#141) (#149)
2026-11-04 18:50:27 +03:00

303 lines
7.8 KiB
TypeScript

import createClient from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema";
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const HTTP_REQUEST_TIMEOUT = 408;
const HTTP_TOO_MANY_REQUESTS = 429;
const HTTP_INTERNAL_SERVER_ERROR = 500;
const HTTP_BAD_GATEWAY = 502;
const HTTP_SERVICE_UNAVAILABLE = 503;
const HTTP_GATEWAY_TIMEOUT = 504;
const GET_DEADLINE_MS = 15_000;
const FIRST_RETRY_DELAY_MS = 1000;
const SECOND_RETRY_DELAY_MS = 2000;
const RETRY_DELAYS_MS = [FIRST_RETRY_DELAY_MS, SECOND_RETRY_DELAY_MS] as const;
const SECONDS_TO_MS = 1000;
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
const apiBaseUrl
= typeof configuredBaseUrl === "string"
? configuredBaseUrl
: "http://localhost:3000";
type AuthHandlers = {
accessToken: () => null
| string
| undefined;
clear: () => void;
generation?: () => number;
refresh: () => Promise<boolean>;
/**
True after a refresh failed transiently (network/5xx): the session is not proven gone.
*/
unavailable?: () => boolean;
};
type ReadAttempt = {
problem?: unknown;
reason: "abort" | "transient";
response?: Response;
};
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
hasSession: false,
};
const authSessionListeners = new Set<() => void>();
function abortError() {
return new DOMException("Request aborted", "AbortError");
}
async function authenticatedFetch(request: Request): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const session = sessionState.handlers;
const generation = session?.generation?.();
const isCurrent = () =>
(session?.generation
? generation === session.generation()
&& generation === sessionState.handlers?.generation?.()
: session === sessionState.handlers);
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
return new Request(source, { headers });
};
const authorized
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetchAppRead(authorized, isAuthRequest);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
isAuthRequest
|| !sessionState.handlers
|| response.status !== HTTP_UNAUTHORIZED
)
return response;
let problem: unknown;
try {
problem = await response.clone().json();
}
catch {
return response;
}
if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized))
return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await sessionState.handlers.refresh())) {
if (isCurrent() && !sessionState.handlers.unavailable?.())
sessionState.handlers.clear();
return response;
}
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetchAppRead(
withToken(retryRequest, refreshedToken),
isAuthRequest,
);
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
function configureAuthClient(handlers?: AuthHandlers) {
sessionState.handlers = handlers;
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
}
async function fetchAppRead(
request: Request,
isAuthRequest: boolean,
): Promise<Response> {
if (isAuthRequest || request.method !== "GET") return fetch(request);
const retryableRequest = request.clone();
for (const [retryIndex, delayMs] of RETRY_DELAYS_MS.entries()) {
const attempt = await readAttempt(
retryIndex === 0 ? request : retryableRequest.clone(),
);
if (attempt.reason === "abort") throw abortError();
const response = attempt.response;
if (response && !isTransientReadFailure(response, attempt.problem))
return response;
await waitForRetry(delayMs, request.signal);
}
const finalAttempt = await readAttempt(retryableRequest.clone());
if (finalAttempt.reason === "abort") throw abortError();
if (!finalAttempt.response) throw new TypeError("Network request failed");
return finalAttempt.response;
}
function hasAuthenticatedSession() {
return sessionState.hasSession;
}
function isNetworkFailure(error: unknown): boolean {
return error instanceof TypeError;
}
function isPermanentCode(code?: string): boolean {
return isBrokerageAccessLoss(code)
|| isApiErrorCode(code, ApiErrorCode.Unauthorized);
}
function isTransientReadFailure(
response: Response | undefined,
problem: unknown,
): boolean {
if (!response) return true;
const code = apiErrorCode(problem);
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return true;
if (isPermanentCode(code)) return false;
const transientStatuses = [
HTTP_BAD_GATEWAY,
HTTP_GATEWAY_TIMEOUT,
HTTP_INTERNAL_SERVER_ERROR,
HTTP_REQUEST_TIMEOUT,
HTTP_SERVICE_UNAVAILABLE,
];
return transientStatuses.includes(response.status)
|| (
response.status === HTTP_TOO_MANY_REQUESTS
&& retryAfterMs(response) === 0
);
}
function parseJson(body: string): unknown {
try {
return JSON.parse(body);
}
catch {
return undefined;
}
}
async function readAttempt(request: Request): Promise<ReadAttempt> {
// Manual timeout (not AbortSignal.timeout) so fake-timer tests can advance it.
const timeout = new AbortController();
const timer = setTimeout(() => timeout.abort(), GET_DEADLINE_MS);
const signal = AbortSignal.any([request.signal, timeout.signal]);
try {
// MSW keeps a stalled body readable after abort, so keep the race.
const response = await Promise.race([
fetch(new Request(request, { signal })),
rejectOnAbort(signal),
]);
const body = await Promise.race([response.text(), rejectOnAbort(signal)]);
return {
problem: parseJson(body),
reason: "transient",
response: new Response(body, response),
};
}
catch (error) {
if (request.signal.aborted) return { reason: "abort" };
// Timeout or network failure: retryable.
if (timeout.signal.aborted || isNetworkFailure(error))
return { reason: "transient" };
throw error;
}
finally {
clearTimeout(timer);
}
}
function rejectOnAbort(signal: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
// The signal is either already aborted or one of two parents firing once.
signal.addEventListener(
"abort",
() => reject(abortError()),
{ once: true },
);
});
}
function retryAfterMs(response: Response): number | undefined {
const header = response.headers.get("Retry-After");
if (!header) return undefined;
const seconds = Number(header);
return Number.isFinite(seconds) ? seconds * SECONDS_TO_MS : undefined;
}
function setAuthenticatedSession(hasSession: boolean) {
if (hasSession === sessionState.hasSession) return;
sessionState.hasSession = hasSession;
for (const listener of authSessionListeners) listener();
}
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener);
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, ms);
const abort = () => {
clearTimeout(timeout);
reject(abortError());
};
signal.addEventListener("abort", abort, { once: true });
});
}
const apiClient = createClient<paths>({
baseUrl: apiBaseUrl,
credentials: "include",
fetch: authenticatedFetch,
});
const apiQueryClient = createQueryClient(apiClient);
export {
apiClient,
apiQueryClient,
configureAuthClient,
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
subscribeAuthenticatedSession,
};