From 1a9199a92b550af0a6eff1f476c46bb0c9d61cb2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:29:43 +0300 Subject: [PATCH] feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55) --- src/shared/api/index.ts | 1 + src/shared/api/read-failure.test.ts | 80 ++++++++++++++++++++++ src/shared/api/read-failure.ts | 37 ++++++++++ src/shared/i18n/locales/ru.ts | 2 + src/shared/ui/ReadRetry/ReadRetry.test.tsx | 37 ++++++++++ src/shared/ui/ReadRetry/ReadRetry.tsx | 42 ++++++++++++ src/shared/ui/index.ts | 1 + 7 files changed, 200 insertions(+) create mode 100644 src/shared/api/read-failure.test.ts create mode 100644 src/shared/api/read-failure.ts create mode 100644 src/shared/ui/ReadRetry/ReadRetry.test.tsx create mode 100644 src/shared/ui/ReadRetry/ReadRetry.tsx diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index bbd54da..60bc969 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -13,6 +13,7 @@ export { isApiErrorCode, isBrokerageAccessLoss, } from "./problem-details"; +export { isReadFailureExhausted, readRetry } from "./read-failure"; export { type InvestmentResourceMeta, diff --git a/src/shared/api/read-failure.test.ts b/src/shared/api/read-failure.test.ts new file mode 100644 index 0000000..0824687 --- /dev/null +++ b/src/shared/api/read-failure.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isReadFailureExhausted, readRetry } from "./read-failure"; + +const transient = { code: "TBANK_UNAVAILABLE", status: 503 }; +const settled = { isError: true, isFetching: false }; + +describe("isReadFailureExhausted", () => { + it("is true for a settled transient failure, even without a code", () => { + expect(isReadFailureExhausted({ ...settled, error: transient })).toBe(true); + expect( + isReadFailureExhausted({ + ...settled, + error: new TypeError("Network request failed"), + }), + ).toBe(true); + }); + + it("stays true while a manual retry runs and false without a failure", () => { + expect( + isReadFailureExhausted({ + error: transient, + isError: true, + isFetching: true, + }), + ).toBe(true); + expect( + isReadFailureExhausted({ + error: null, + isError: false, + isFetching: false, + }), + ).toBe(false); + }); + + it.each([ + "INVALID_TBANK_TOKEN", + "NOT_FOUND", + "TBANK_NOT_CONNECTED", + "UNAUTHORIZED", + ])("is false for %s, which has its own action", (code) => { + expect(isReadFailureExhausted({ ...settled, error: { code } })).toBe(false); + }); +}); + +describe("readRetry", () => { + it("refetches only the failed reads and reports a running retry", () => { + const failedRefetch = vi.fn(() => Promise.resolve()); + const healthyRefetch = vi.fn(() => Promise.resolve()); + + const retry = readRetry([ + { ...settled, error: transient, refetch: failedRefetch }, + { + error: null, + isError: false, + isFetching: true, + refetch: healthyRefetch, + }, + { ...settled, error: { code: "NOT_FOUND" }, refetch: healthyRefetch }, + ]); + + expect(retry).toMatchObject({ exhausted: true, retrying: false }); + retry.onRetry(); + expect(failedRefetch).toHaveBeenCalledOnce(); + expect(healthyRefetch).not.toHaveBeenCalled(); + }); + + it("is not exhausted while every read is healthy", () => { + expect( + readRetry([ + { + error: null, + isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve()), + }, + ]).exhausted, + ).toBe(false); + }); +}); diff --git a/src/shared/api/read-failure.ts b/src/shared/api/read-failure.ts new file mode 100644 index 0000000..99ad715 --- /dev/null +++ b/src/shared/api/read-failure.ts @@ -0,0 +1,37 @@ +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; + +type ReadState = { error: unknown; isError: boolean; isFetching: boolean }; + +type RetryableRead = ReadState & { refetch: () => Promise }; + +// Automatic retries run inside one fetchAppRead call and Query's own retry is +// off, so a query in the error state has exhausted them. The state stays +// "error" while a manual retry runs, which keeps the warning in place. Access +// loss and a lost session keep their own actions and never offer a plain retry. +function isReadFailureExhausted(state: ReadState): boolean { + if (!state.isError) return false; + const code = apiErrorCode(state.error); + + return !isBrokerageAccessLoss(code) + && !isApiErrorCode(code, ApiErrorCode.Unauthorized); +} + +// Retry repeats only the GET of the failed queries, never a source sync. +function readRetry(reads: RetryableRead[]) { + const failed = reads.filter(read => isReadFailureExhausted(read)); + + return { + exhausted: failed.length > 0, + onRetry: () => { + void Promise.allSettled(failed.map(read => read.refetch())); + }, + retrying: failed.some(read => read.isFetching), + }; +} + +export { isReadFailureExhausted, readRetry }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 1e32cb3..d6fcc35 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -421,7 +421,9 @@ const ru = { }, history: "История догружается", partial: "Период загружен не полностью", + readFailed: "Не удалось обновить данные", refresh: "Обновить", + retry: "Повторить", sourceTime: "Данные на {{date}}", stale: "Данные требуют обновления", syncFailed: diff --git a/src/shared/ui/ReadRetry/ReadRetry.test.tsx b/src/shared/ui/ReadRetry/ReadRetry.test.tsx new file mode 100644 index 0000000..86f0b97 --- /dev/null +++ b/src/shared/ui/ReadRetry/ReadRetry.test.tsx @@ -0,0 +1,37 @@ +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ReadRetry } from "./ReadRetry"; + +function renderRetry(props: Parameters[0]) { + return render( + + + , + ); +} + +describe("ReadRetry", () => { + it("renders nothing until the automatic retries are exhausted", () => { + renderRetry({ exhausted: false, onRetry: vi.fn() }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("announces the failure politely and retries on activation", () => { + const onRetry = vi.fn(); + renderRetry({ exhausted: true, onRetry }); + + expect(screen.getByRole("status")).toHaveTextContent("Не удалось обновить данные"); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("disables the action while the retry runs", () => { + renderRetry({ exhausted: true, onRetry: vi.fn(), retrying: true }); + + expect(screen.getByRole("button", { name: "Повторить" })).toBeDisabled(); + }); +}); diff --git a/src/shared/ui/ReadRetry/ReadRetry.tsx b/src/shared/ui/ReadRetry/ReadRetry.tsx new file mode 100644 index 0000000..eafa933 --- /dev/null +++ b/src/shared/ui/ReadRetry/ReadRetry.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; + +import { Alert, Button, Group, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +// Shown only after the automatic read retries are exhausted. The action repeats +// the failed GET of the affected query; it never starts a source sync. +type ReadRetryProps = { + exhausted: boolean; + onRetry: () => void; + retrying?: boolean; +}; + +const ReadRetryButton: FC> = ( + props, +) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const ReadRetry: FC = (props) => { + const { t } = useTranslation(); + + if (!props.exhausted) return null; + + return ( + + + {t("investment.readFailed")} + + + + + ); +}; + +export { ReadRetry, ReadRetryButton, type ReadRetryProps }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 88a1681..495ee4f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,4 +5,5 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; +export { ReadRetry, ReadRetryButton } from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";