mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 22:21:24 +00:00
Load nodes 200 at a time and wait out a rate limit (#175)
Map and Nodes fetch a region at the server's 200 cap instead of 50, a quarter of the requests. A 429 now pauses paging until Retry-After and retries the refused page, instead of stopping with "Some nodes failed to load".
This commit is contained in:
1 parent
a8cd595b35
commit
f4098111e7
8 files changed
+84
-9
No files matched your search
@@ -26,6 +26,13 @@ export function shouldRetryQuery(failureCount: number, error: unknown): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// How long to hold off before asking again for what a 429 refused; undefined for any other error.
|
||||
export function rateLimitWaitMs(error: unknown): number | undefined {
|
||||
const e = error as { status?: unknown; retryAfterMs?: unknown } | null;
|
||||
if (e?.status !== 429) return undefined;
|
||||
return typeof e.retryAfterMs === "number" ? e.retryAfterMs : RATE_LIMIT_DEFAULT_MS;
|
||||
}
|
||||
|
||||
// App-wide "the API is throttling us" flag: fed by the fetch wrapper, read by the header badge.
|
||||
|
||||
let limitedUntil: number | null = null;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useInfinitePages } from "../../hooks/useInfinitePages";
|
||||
import { getNodesPage } from "../../api/client";
|
||||
import { MAX_PAGE_SIZE } from "../../lib/constants";
|
||||
import type { NodeSummary } from "../nodes/types";
|
||||
|
||||
const nodeId = (n: NodeSummary) => n.id;
|
||||
|
||||
// Page the selected region's nodes 50 at a time for the map, so the canvas fills batch by batch
|
||||
// Page the selected region's nodes 200 at a time for the map, so the canvas fills batch by batch
|
||||
// instead of waiting for one big response. Thin wrapper over the shared useInfinitePages (which owns
|
||||
// the auto-chain, dedup, and error handling). Loads once per region; WS updates keep nodes live.
|
||||
export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string, opts?: { enabled?: boolean }) {
|
||||
@@ -12,7 +13,7 @@ export function useMapNodesData(selectedIatas: string[] | undefined, regionKey:
|
||||
queryKey: ["map-nodes", regionKey],
|
||||
// Always request neighborIds (just UUIDs) so the neighbor-lines toggle is a pure client-side
|
||||
// render switch over already-loaded data — no refetch when toggling.
|
||||
queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true }),
|
||||
queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true, limit: MAX_PAGE_SIZE }),
|
||||
getId: nodeId,
|
||||
enabled: opts?.enabled,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { CursorPage } from "../../types/api";
|
||||
import type { WsManager } from "../../api/ws-manager";
|
||||
import type { WsNodeUpdate } from "../../types/ws";
|
||||
import { EmojiText } from "../../components/EmojiText";
|
||||
import { MAX_PAGE_SIZE } from "../../lib/constants";
|
||||
|
||||
const nodeId = (n: NodeSummary) => n.id; // stable id accessor for the paged hook's dedup and the table's row keys
|
||||
|
||||
@@ -180,13 +181,14 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
|
||||
[regionKey, typeFilter, pathsFilter, tracesFilter, nameParam, pubkeyPrefixParam],
|
||||
);
|
||||
|
||||
// page the region's nodes 50 at a time (filters stay server-side, in the query key); rows stream
|
||||
// page the region's nodes 200 at a time (filters stay server-side, in the query key); rows stream
|
||||
// in as each batch lands. Loads once per filter set — WS updates keep them live, no 30s refetch.
|
||||
const { items: nodes, loadedCount, isPaging, isError, isLoading } = useInfinitePages<NodeSummary>({
|
||||
queryKey,
|
||||
queryFn: (cursor) =>
|
||||
getNodesPage(iatas, {
|
||||
cursor,
|
||||
limit: MAX_PAGE_SIZE,
|
||||
type: typeFilter || undefined,
|
||||
name: nameParam,
|
||||
pubkeyPrefix: pubkeyPrefixParam,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useInfiniteQuery, keepPreviousData, type QueryKey } from "@tanstack/react-query";
|
||||
import type { CursorPage } from "../types/api";
|
||||
import { rateLimitWaitMs } from "../api/rate-limit";
|
||||
|
||||
interface UseInfinitePagesOptions<T, C> {
|
||||
queryKey: QueryKey;
|
||||
@@ -24,7 +25,7 @@ interface UseInfinitePagesOptions<T, C> {
|
||||
// Infinity, no maxPages); dedupes by id because a non-unique cursor can repeat a row across a page
|
||||
// boundary. Shared by the map and the entity tables.
|
||||
export function useInfinitePages<T, C = number>({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions<T, C>) {
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } =
|
||||
const { data, error, refetch, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }) => queryFn(pageParam as C | undefined),
|
||||
@@ -48,6 +49,15 @@ export function useInfinitePages<T, C = number>({ queryKey, queryFn, getId, keep
|
||||
if (auto && enabled) loadMore();
|
||||
}, [auto, enabled, loadMore]);
|
||||
|
||||
// A 429 is a pause, not a failure: once Retry-After has passed, ask again for the page it refused.
|
||||
const rateLimited = rateLimitWaitMs(error) !== undefined;
|
||||
useEffect(() => {
|
||||
const wait = rateLimitWaitMs(error);
|
||||
if (wait === undefined || !enabled || isFetching) return;
|
||||
const timer = setTimeout(() => void (isFetchNextPageError ? fetchNextPage() : refetch()), wait);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, enabled, isFetching, isFetchNextPageError, fetchNextPage, refetch]);
|
||||
|
||||
const items = useMemo<T[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
return (data?.pages.flatMap((p) => p.items) ?? []).filter((it) => {
|
||||
@@ -58,16 +68,16 @@ export function useInfinitePages<T, C = number>({ queryKey, queryFn, getId, keep
|
||||
});
|
||||
}, [data, getId]);
|
||||
|
||||
const errored = isError || isFetchNextPageError;
|
||||
const errored = (isError || isFetchNextPageError) && !rateLimited;
|
||||
return {
|
||||
items,
|
||||
loadedCount: items.length,
|
||||
// drop the loading state on error so the pill hides instead of hanging on (hasNextPage stays true
|
||||
// after a failed page); callers surface isError separately. In auto mode hasNextPage counts as
|
||||
// "still paging" (more is coming); in on-demand mode only an in-flight fetch does.
|
||||
isPaging: (isFetching || (auto && hasNextPage)) && !errored,
|
||||
isPaging: (isFetching || (auto && hasNextPage) || rateLimited) && !errored,
|
||||
isError: errored,
|
||||
isLoading,
|
||||
isLoading: isLoading || (rateLimited && data === undefined),
|
||||
hasMore: hasNextPage && !errored,
|
||||
loadMore,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ export const WS_URL = runtimeEnv("VITE_WS_URL", import.meta.env.VITE_WS_URL) ??
|
||||
|
||||
export const LIVE_BUFFER_CAP = 500;
|
||||
export const DEFAULT_PAGE_SIZE = 50;
|
||||
// beacon-server's list cap (maxListLimit); views that load a whole region page at it to stay under the rate limit
|
||||
export const MAX_PAGE_SIZE = 200;
|
||||
|
||||
export const SCROLL_TOP_THRESHOLD_PX = 100;
|
||||
export const SCROLL_BOTTOM_THRESHOLD_PX = 500;
|
||||
|
||||
@@ -38,8 +38,9 @@ describe("useMapNodesData", () => {
|
||||
expect(result.current.nodes.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
expect(result.current.loadedCount).toBe(3);
|
||||
expect(mockGetNodesPage).toHaveBeenCalledTimes(2);
|
||||
// second call paginates with the previous page's nextCursor; the map always requests neighbor ids
|
||||
expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2, neighbors: true });
|
||||
// second call paginates with the previous page's nextCursor; the map always requests neighbor ids,
|
||||
// at the server's page cap so a whole region takes a quarter of the requests
|
||||
expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2, neighbors: true, limit: 200 });
|
||||
});
|
||||
|
||||
it("stops after a single page when hasMore is false", async () => {
|
||||
|
||||
@@ -64,6 +64,14 @@ describe("NodeTable name column", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeTable paging", () => {
|
||||
it("asks for nodes at the server's page cap, so a whole region takes a quarter of the requests", async () => {
|
||||
mount([node({ id: "node-a", name: "Node A" })]);
|
||||
await screen.findByText("Node A");
|
||||
expect(vi.mocked(getNodesPage)).toHaveBeenCalledWith(["YVR"], expect.objectContaining({ limit: 200 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeTable location column", () => {
|
||||
it("shows a dash for an explicit 0/0 advert reset, not the coordinates", async () => {
|
||||
mount([node({ id: "node-zero", name: "Zeroed node", lat: 0, lng: 0 })]);
|
||||
|
||||
@@ -69,6 +69,50 @@ describe("useInfinitePages", () => {
|
||||
expect(result.current.items.map(rowId)).toEqual(["a"]); // page 1 still shows
|
||||
});
|
||||
|
||||
it("waits out a rate limit and carries on from the refused page", async () => {
|
||||
const limited = Object.assign(new Error("rate limited"), { status: 429, retryAfterMs: 300 });
|
||||
const queryFn = vi.fn();
|
||||
queryFn
|
||||
.mockResolvedValueOnce({ items: [row("a")], nextCursor: 1, hasMore: true })
|
||||
.mockRejectedValueOnce(limited)
|
||||
.mockResolvedValueOnce({ items: [row("b")], nextCursor: null, hasMore: false });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfinitePages({ queryKey: ["rl", 1], queryFn, getId: rowId }),
|
||||
{ wrapper: wrapper() },
|
||||
);
|
||||
|
||||
// refused: still paging, not an error, and nothing retried before Retry-After
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.isError).toBe(false);
|
||||
expect(result.current.isPaging).toBe(true);
|
||||
|
||||
await waitFor(() => expect(result.current.items.map(rowId)).toEqual(["a", "b"]));
|
||||
expect(queryFn).toHaveBeenNthCalledWith(3, 1); // the same page again, not a skip
|
||||
expect(result.current.isPaging).toBe(false);
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it("waits out a rate limit on the first page too, showing it as loading", async () => {
|
||||
const limited = Object.assign(new Error("rate limited"), { status: 429, retryAfterMs: 300 });
|
||||
const queryFn = vi.fn();
|
||||
queryFn
|
||||
.mockRejectedValueOnce(limited)
|
||||
.mockResolvedValueOnce({ items: [row("a")], nextCursor: null, hasMore: false });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useInfinitePages({ queryKey: ["rl", 2], queryFn, getId: rowId }),
|
||||
{ wrapper: wrapper() },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(true));
|
||||
expect(result.current.isError).toBe(false);
|
||||
|
||||
await waitFor(() => expect(result.current.items.map(rowId)).toEqual(["a"]));
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it("with auto:false, loads only the first page until loadMore is called", async () => {
|
||||
const queryFn = vi.fn();
|
||||
queryFn
|
||||
|
||||
Reference in new issue
Block a user