feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55)
This commit is contained in:
@@ -13,6 +13,7 @@ export {
|
||||
isApiErrorCode,
|
||||
isBrokerageAccessLoss,
|
||||
} from "./problem-details";
|
||||
export { isReadFailureExhausted, readRetry } from "./read-failure";
|
||||
|
||||
export {
|
||||
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: "История догружается",
|
||||
partial: "Период загружен не полностью",
|
||||
readFailed: "Не удалось обновить данные",
|
||||
refresh: "Обновить",
|
||||
retry: "Повторить",
|
||||
sourceTime: "Данные на {{date}}",
|
||||
stale: "Данные требуют обновления",
|
||||
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,
|
||||
} from "./EntryGroup/EntryGroup";
|
||||
export { PayoutCard } from "./PayoutCard/PayoutCard";
|
||||
export { ReadRetry, ReadRetryButton } from "./ReadRetry/ReadRetry";
|
||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||
|
||||
Reference in New Issue
Block a user