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:
MrAlders0n authored and GitHub committed 2026-10-18 23:13:04 -04:00
1 parent a8cd595b35
commit f4098111e7
8 files changed
+84 -9

No files matched your search

+7
View File
@@ -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;
+3 -2
View File
@@ -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,
});
+3 -1
View File
@@ -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,
+14 -4
View File
@@ -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,
};
+2
View File
@@ -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;
+3 -2
View File
@@ -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 () => {
+8
View File
@@ -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 })]);
+44
View File
@@ -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