fix(license): keep one boundary refetch chain and toast only its first failure

This commit is contained in:
Ashish Kolhe
2026-11-04 20:18:57 +05:30
parent fda6e220ac
commit 3e80dc4ea9
2 changed files with 114 additions and 15 deletions
+89 -4
View File
@@ -1938,14 +1938,99 @@ describe("License.vue", () => {
expect(licenseServer.get_license).toHaveBeenCalledTimes(3);
});
it("drops a pending refetch on unmount", async () => {
vi.mocked(licenseServer.get_license).mockResolvedValue(response({}));
const deferred = () => {
let resolve: (value: AxiosResponse) => void = () => {};
const promise = new Promise<AxiosResponse>((r) => (resolve = r));
return { promise, resolve };
};
const errorToasts = () =>
mockToast.mock.calls.filter(([options]) => options?.variant === "error").length;
it("drops a pending refetch on unmount, including one a boundary set during a refetch", async () => {
const inFlight = deferred();
vi.mocked(licenseServer.get_license)
.mockResolvedValueOnce(response({}))
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValue(response({}));
wrapper = createWrapper();
await flushPromises();
await vi.advanceTimersByTimeAsync(1500);
await vi.advanceTimersByTimeAsync(2500);
expect(licenseServer.get_license).toHaveBeenCalledTimes(2);
wrapper.vm.refetchAtBoundary(graceEndUs);
inFlight.resolve(response({}));
await flushPromises();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3);
expect(licenseServer.get_license).toHaveBeenCalledTimes(1);
expect(licenseServer.get_license).toHaveBeenCalledTimes(2);
});
it("schedules nothing when a refetch in flight finishes after unmount", async () => {
const inFlight = deferred();
vi.mocked(licenseServer.get_license)
.mockResolvedValueOnce(response({}))
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValue(response({}));
wrapper = createWrapper();
await flushPromises();
await vi.advanceTimersByTimeAsync(2500);
wrapper.unmount();
inFlight.resolve(response({}));
await flushPromises();
await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3);
expect(licenseServer.get_license).toHaveBeenCalledTimes(2);
});
it("keeps a single retry chain when a boundary arrives during a refetch", async () => {
const inFlight = deferred();
vi.mocked(licenseServer.get_license)
.mockResolvedValueOnce(response({}))
.mockReturnValueOnce(inFlight.promise)
.mockResolvedValue(response({}));
wrapper = createWrapper();
await flushPromises();
await vi.advanceTimersByTimeAsync(2500);
expect(licenseServer.get_license).toHaveBeenCalledTimes(2);
wrapper.vm.refetchAtBoundary(graceEndUs);
inFlight.resolve(response({}));
await flushPromises();
await vi.advanceTimersByTimeAsync(1000);
expect(licenseServer.get_license).toHaveBeenCalledTimes(3);
for (let minute = 1; minute <= 3; minute++) {
await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS);
expect(licenseServer.get_license).toHaveBeenCalledTimes(3 + minute);
}
});
it("toasts only the first failure of a boundary retry chain", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const gets = () => vi.mocked(licenseServer.get_license).mock.calls.length;
vi.mocked(licenseServer.get_license)
.mockResolvedValueOnce(response({}))
.mockRejectedValue({ response: { status: 503 } });
wrapper = createWrapper();
await flushPromises();
mockToast.mockClear();
await vi.advanceTimersByTimeAsync(2500 + 5000);
expect(gets()).toBeGreaterThan(1);
expect(errorToasts()).toBe(1);
const userReload = wrapper.vm.reloadLicenseData();
await vi.advanceTimersByTimeAsync(5000);
await userReload;
expect(errorToasts()).toBe(2);
const beforeRetries = gets();
await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 3);
expect(gets()).toBeGreaterThan(beforeRetries + 2);
expect(errorToasts()).toBe(2);
wrapper.vm.refetchAtBoundary(graceEndUs + 1);
await vi.advanceTimersByTimeAsync(1000 + 5000);
expect(errorToasts()).toBe(3);
await vi.advanceTimersByTimeAsync(LICENSE_BOUNDARY_RETRY_MS * 2);
expect(errorToasts()).toBe(3);
consoleErrorSpy.mockRestore();
});
});
+25 -11
View File
@@ -721,17 +721,21 @@ export default defineComponent({
};
// Only the first load may swap the page for the spinner; later reloads would unmount the form.
const loadLicenseData = async ({ background = false } = {}) => {
const loadLicenseData = async ({ background = false, quiet = false } = {}) => {
try {
if (!background) loading.value = true;
licenseData.value = await queryClient.fetchQuery(licenseQuery());
if (!background) checkAndAutoFillLicenseFromUrl();
return true;
} catch (error) {
console.error("Error loading license data:", error);
toast({
variant: "error",
message: t("about.failed_to_load_license_info"),
});
if (!quiet) {
toast({
variant: "error",
message: t("about.failed_to_load_license_info"),
});
}
return false;
} finally {
if (!background) loading.value = false;
}
@@ -742,26 +746,36 @@ export default defineComponent({
let boundaryRefetch: ReturnType<typeof setTimeout> | undefined;
let boundaryUs = 0;
let boundaryRetries = 0;
let boundaryChain = 0;
let boundaryErrorShown = false;
let unmounted = false;
const clearBoundaryRefetch = () => {
if (boundaryRefetch !== undefined) clearTimeout(boundaryRefetch);
boundaryRefetch = undefined;
};
const scheduleBoundaryRefetch = (chain: number, delayMs: number) => {
clearBoundaryRefetch();
boundaryRefetch = setTimeout(() => refetchAfterBoundary(chain), delayMs);
};
// the server re-evaluates on its own tick, so a decision from before the boundary is fetched again
const refetchAfterBoundary = async () => {
const refetchAfterBoundary = async (chain: number) => {
boundaryRefetch = undefined;
await reloadLicenseData();
const loaded = await loadLicenseData({ background: true, quiet: boundaryErrorShown });
if (!loaded && chain === boundaryChain) boundaryErrorShown = true;
// a boundary that arrived during the fetch started a newer chain, which owns the timer
if (unmounted || chain !== boundaryChain) return;
const evaluatedAt = Number(licenseData.value?.evaluated_at ?? 0);
if (unmounted || !(evaluatedAt > 0 && evaluatedAt < boundaryUs)) return;
if (!(evaluatedAt > 0 && evaluatedAt < boundaryUs)) return;
if (boundaryRetries >= LICENSE_BOUNDARY_RETRIES) return;
boundaryRetries++;
boundaryRefetch = setTimeout(refetchAfterBoundary, LICENSE_BOUNDARY_RETRY_MS);
scheduleBoundaryRefetch(chain, LICENSE_BOUNDARY_RETRY_MS);
};
const refetchAtBoundary = (atUs: number) => {
boundaryUs = Math.max(boundaryUs, atUs);
boundaryRetries = 0;
clearBoundaryRefetch();
boundaryRefetch = setTimeout(refetchAfterBoundary, LICENSE_BOUNDARY_DEBOUNCE_MS);
boundaryChain++;
boundaryErrorShown = false;
scheduleBoundaryRefetch(boundaryChain, LICENSE_BOUNDARY_DEBOUNCE_MS);
};
onBeforeUnmount(() => {
unmounted = true;