feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
This commit was merged in pull request #136.
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 };
|
||||
Reference in New Issue
Block a user