diff --git a/src/api/rate-limit.ts b/src/api/rate-limit.ts index 40e6629..83686a9 100644 --- a/src/api/rate-limit.ts +++ b/src/api/rate-limit.ts @@ -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; diff --git a/src/features/map/useMapNodesData.ts b/src/features/map/useMapNodesData.ts index e720d72..b166209 100644 --- a/src/features/map/useMapNodesData.ts +++ b/src/features/map/useMapNodesData.ts @@ -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, }); diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 3fdb16e..7d76924 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -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({ queryKey, queryFn: (cursor) => getNodesPage(iatas, { cursor, + limit: MAX_PAGE_SIZE, type: typeFilter || undefined, name: nameParam, pubkeyPrefix: pubkeyPrefixParam, diff --git a/src/hooks/useInfinitePages.ts b/src/hooks/useInfinitePages.ts index 4bd13a9..7f1c2e5 100644 --- a/src/hooks/useInfinitePages.ts +++ b/src/hooks/useInfinitePages.ts @@ -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 { queryKey: QueryKey; @@ -24,7 +25,7 @@ interface UseInfinitePagesOptions { // 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({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions) { - 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({ 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(() => { const seen = new Set(); return (data?.pages.flatMap((p) => p.items) ?? []).filter((it) => { @@ -58,16 +68,16 @@ export function useInfinitePages({ 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, }; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 317b56b..cf0e8e2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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; diff --git a/tests/features/map/useMapNodesData.test.tsx b/tests/features/map/useMapNodesData.test.tsx index 33f45ba..8386cd5 100644 --- a/tests/features/map/useMapNodesData.test.tsx +++ b/tests/features/map/useMapNodesData.test.tsx @@ -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 () => { diff --git a/tests/features/nodes/NodeTable.test.tsx b/tests/features/nodes/NodeTable.test.tsx index 9187b63..8b7f78f 100644 --- a/tests/features/nodes/NodeTable.test.tsx +++ b/tests/features/nodes/NodeTable.test.tsx @@ -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 })]); diff --git a/tests/hooks/useInfinitePages.test.tsx b/tests/hooks/useInfinitePages.test.tsx index 46d317e..06c30dc 100644 --- a/tests/hooks/useInfinitePages.test.tsx +++ b/tests/hooks/useInfinitePages.test.tsx @@ -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