feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55)
This commit is contained in:
@@ -13,6 +13,7 @@ export {
|
|||||||
isApiErrorCode,
|
isApiErrorCode,
|
||||||
isBrokerageAccessLoss,
|
isBrokerageAccessLoss,
|
||||||
} from "./problem-details";
|
} from "./problem-details";
|
||||||
|
export { isReadFailureExhausted, readRetry } from "./read-failure";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type InvestmentResourceMeta,
|
type InvestmentResourceMeta,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<unknown> };
|
||||||
|
|
||||||
|
// 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 };
|
||||||
@@ -421,7 +421,9 @@ const ru = {
|
|||||||
},
|
},
|
||||||
history: "История догружается",
|
history: "История догружается",
|
||||||
partial: "Период загружен не полностью",
|
partial: "Период загружен не полностью",
|
||||||
|
readFailed: "Не удалось обновить данные",
|
||||||
refresh: "Обновить",
|
refresh: "Обновить",
|
||||||
|
retry: "Повторить",
|
||||||
sourceTime: "Данные на {{date}}",
|
sourceTime: "Данные на {{date}}",
|
||||||
stale: "Данные требуют обновления",
|
stale: "Данные требуют обновления",
|
||||||
syncFailed:
|
syncFailed:
|
||||||
|
|||||||
@@ -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<typeof ReadRetry>[0]) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<ReadRetry {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Pick<ReadRetryProps, "onRetry" | "retrying">> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button disabled={props.retrying} onClick={props.onRetry} variant="light">
|
||||||
|
{t("investment.retry")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReadRetry: FC<ReadRetryProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!props.exhausted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert aria-live="polite" color="orange" role="status">
|
||||||
|
<Group gap="sm" justify="space-between">
|
||||||
|
<Text>{t("investment.readFailed")}</Text>
|
||||||
|
|
||||||
|
<ReadRetryButton onRetry={props.onRetry} retrying={props.retrying} />
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ReadRetry, ReadRetryButton, type ReadRetryProps };
|
||||||
@@ -5,4 +5,5 @@ export {
|
|||||||
EntryGroupTotal,
|
EntryGroupTotal,
|
||||||
} from "./EntryGroup/EntryGroup";
|
} from "./EntryGroup/EntryGroup";
|
||||||
export { PayoutCard } from "./PayoutCard/PayoutCard";
|
export { PayoutCard } from "./PayoutCard/PayoutCard";
|
||||||
|
export { ReadRetry, ReadRetryButton } from "./ReadRetry/ReadRetry";
|
||||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||||
|
|||||||
Reference in New Issue
Block a user