feat: add quiet reading with rare confirmations

Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+198 -1
View File
@@ -1,12 +1,26 @@
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client";
async function advanceReadDeadlineRecovery() {
await vi.advanceTimersByTimeAsync(15_000);
await vi.advanceTimersByTimeAsync(1000);
}
async function advanceTransportRecovery() {
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
}
describe("apiClient authentication", () => {
afterEach(() => {
vi.useRealTimers();
});
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
@@ -109,3 +123,186 @@ describe("apiClient authentication", () => {
await response;
});
});
describe("apiClient transient read recovery", () => {
afterEach(() => {
vi.useRealTimers();
});
it("recovers a protected GET after transient server failures", async () => {
vi.useFakeTimers();
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return requests < 3
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([{ id: "account-1" }]));
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const response = apiClient.GET("/api/v1/brokerage-accounts");
await advanceTransportRecovery();
await expect(response).resolves.toMatchObject({
data: { data: [{ id: "account-1" }] },
});
expect(requests).toBe(3);
});
it("recovers a protected GET after network failure", async () => {
vi.useFakeTimers();
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return requests === 1
? HttpResponse.error()
: HttpResponse.json(resource([{ id: "account-1" }]));
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const response = apiClient.GET("/api/v1/brokerage-accounts");
await advanceTransportRecovery();
await expect(response).resolves.toMatchObject({
data: { data: [{ id: "account-1" }] },
});
expect(requests).toBe(2);
});
it("times out a stalled response body before retrying", async () => {
vi.useFakeTimers();
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return requests === 1
? new Response(new ReadableStream())
: HttpResponse.json(resource([{ id: "account-1" }]));
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const response = apiClient.GET("/api/v1/brokerage-accounts");
await advanceReadDeadlineRecovery();
await expect(response).resolves.toMatchObject({
data: { data: [{ id: "account-1" }] },
});
expect(requests).toBe(2);
});
it("does not retry rate limited reads before the server delay", async () => {
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return HttpResponse.json(
{ code: "RATE_LIMITED" },
{ headers: { "Retry-After": "60" }, status: 429 },
);
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
await apiClient.GET("/api/v1/brokerage-accounts");
expect(requests).toBe(1);
});
it("does not retry malformed successful responses as transport failures", async () => {
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return new Response("not json", {
headers: { "Content-Type": "application/json" },
status: 200,
});
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
await expect(
apiClient.GET("/api/v1/brokerage-accounts"),
).rejects.toThrow(SyntaxError);
expect(requests).toBe(1);
});
it.each([401, 403, 404, 409])(
"does not retry permanent HTTP %s responses",
async (status) => {
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
return HttpResponse.json({ code: "FORBIDDEN" }, { status });
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
await apiClient.GET("/api/v1/brokerage-accounts");
expect(requests).toBe(1);
},
);
it("does not retry mutations", async () => {
let requests = 0;
server.use(
http.put("*/api/v1/tbank-connection", () => {
requests += 1;
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "token" },
});
expect(requests).toBe(1);
});
});
+169 -2
View File
@@ -6,6 +6,17 @@ import type { paths } from "./generated/schema";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } 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;
@@ -23,12 +34,22 @@ type AuthHandlers = {
refresh: () => Promise<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/",
@@ -56,7 +77,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
const response = await fetchAppRead(authorized, isAuthRequest);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
@@ -87,7 +108,12 @@ async function authenticatedFetch(request: Request): Promise<Response> {
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
const retried = await fetchAppRead(
withToken(retryRequest, refreshedToken),
isAuthRequest,
);
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
@@ -98,10 +124,138 @@ function configureAuthClient(handlers?: AuthHandlers) {
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 [
ApiErrorCode.Forbidden,
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
ApiErrorCode.Unauthorized,
].some(permanent => isApiErrorCode(code, permanent));
}
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> {
const controller = new AbortController();
const failure: { reason?: "abort" | "timeout" } = {};
const { promise: deadline, reject: rejectDeadline }
= Promise.withResolvers<never>();
const timeout = setTimeout(() => {
failure.reason = "timeout";
controller.abort();
rejectDeadline(abortError());
}, GET_DEADLINE_MS);
const abort = () => {
failure.reason = "abort";
controller.abort();
rejectDeadline(abortError());
};
request.signal.addEventListener("abort", abort, { once: true });
const timedRequest = new Request(request, { signal: controller.signal });
try {
const response = await Promise.race([fetch(timedRequest), deadline]);
const body = await Promise.race([response.text(), deadline]);
const problem = parseJson(body);
return {
problem,
reason: "transient",
response: new Response(body, response),
};
}
catch (error) {
if (failure.reason === "abort") return { reason: "abort" };
if (failure.reason === "timeout" || isNetworkFailure(error))
return { reason: "transient" };
throw error;
}
finally {
clearTimeout(timeout);
request.signal.removeEventListener("abort", abort);
}
}
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;
@@ -114,6 +268,19 @@ function subscribeAuthenticatedSession(listener: () => void) {
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",
+7
View File
@@ -76,9 +76,11 @@ const ru = {
bankName: "Банк (необязательно)",
cancel: "Отмена",
card: "Карта",
cardSaved: "Карта сохранена",
categories: "Категории",
category: "Категория",
categoryName: "Название категории",
categorySaved: "Категория сохранена",
close: "Закрыть",
date: "Дата операции",
defaultCategory: "По умолчанию",
@@ -153,7 +155,9 @@ const ru = {
openingHint:
"Остаток до операций с выбранной даты. Валюта — рубли (RUB).",
operationCard: "Карта операции",
operationDeleted: "Операция удалена",
operations: "Операции",
operationSaved: "Операция сохранена",
page: "Страница {{page}}",
pageSize: "Операций на странице",
pagination: "Страницы операций",
@@ -516,6 +520,8 @@ const ru = {
errorTitle: "Что-то пошло не так",
loadingText: "Пожалуйста, подождите.",
loadingTitle: "Загрузка",
unavailableText: "Данные появятся здесь, когда чтение восстановится.",
unavailableTitle: "Пока недоступно",
},
stock: {
aboutCompany: "О компании",
@@ -696,6 +702,7 @@ const ru = {
"Не удалось загрузить статус подключения. Обновите страницу.",
title: "T-Bank Invest",
token: "Token T-Bank",
tokenReplaced: "Подключение обновлено",
tokenRequired: "Введите token",
unavailable: "T-Bank временно недоступен. Повторите попытку позже.",
},
+4
View File
@@ -8,4 +8,8 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export {
type InitialAvailability,
useInitialAvailability,
} from "./initial-availability";
export { useUrlPeriod } from "./useUrlPeriod";
@@ -0,0 +1,107 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useInitialAvailability } from "./initial-availability";
describe("useInitialAvailability", () => {
it("keeps loading until content, error, or the initial wait budget", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: false,
isFetching: true,
timeoutMs: 100,
},
},
);
expect(result.current).toBe("loading");
await act(() => vi.advanceTimersByTimeAsync(99));
expect(result.current).toBe("loading");
await act(() => vi.advanceTimersByTimeAsync(1));
expect(result.current).toBe("unavailable");
rerender({
hasContent: true,
identity: "account-a",
isError: false,
isFetching: false,
timeoutMs: 100,
});
expect(result.current).toBe("ready");
}
finally {
vi.useRealTimers();
}
});
it("does not flash loading during retry for the same identity", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: true,
isFetching: false,
},
},
);
await act(() => vi.runOnlyPendingTimersAsync());
expect(result.current).toBe("unavailable");
rerender({
hasContent: false,
identity: "account-a",
isError: false,
isFetching: true,
});
expect(result.current).toBe("unavailable");
}
finally {
vi.useRealTimers();
}
});
it("resets loading only when identity changes", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: true,
isFetching: false,
},
},
);
await act(() => vi.runOnlyPendingTimersAsync());
expect(result.current).toBe("unavailable");
rerender({
hasContent: false,
identity: "account-b",
isError: false,
isFetching: true,
});
expect(result.current).toBe("loading");
}
finally {
vi.useRealTimers();
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
const INITIAL_WAIT_BUDGET_MS = 10_000;
type InitialAvailability
= | "loading"
| "ready"
| "unavailable";
type InitialAvailabilityInput = {
hasContent: boolean;
identity: string;
isError: boolean;
isFetching: boolean;
timeoutMs?: number;
};
function useInitialAvailability(
input: InitialAvailabilityInput,
): InitialAvailability {
const timeoutMs = input.timeoutMs ?? INITIAL_WAIT_BUDGET_MS;
const [unavailableIdentity, setUnavailableIdentity] = useState<null | string>(
null,
);
useEffect(() => {
if (input.hasContent) return;
if (input.isError) {
const timer = setTimeout(() => setUnavailableIdentity(input.identity), 0);
return () => clearTimeout(timer);
}
const timer = setTimeout(
() => setUnavailableIdentity(input.identity),
timeoutMs,
);
return () => clearTimeout(timer);
}, [input.hasContent, input.identity, input.isError, timeoutMs]);
if (input.hasContent) return "ready";
return unavailableIdentity === input.identity ? "unavailable" : "loading";
}
export { type InitialAvailability, useInitialAvailability };