diff --git a/src/App.tsx b/src/App.tsx index d64fd9b..950ac21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; import { usePacketDetail } from "./features/packets/usePacketDetail"; import { WsManager } from "./api/ws-manager"; +import { shouldRetryQuery } from "./api/rate-limit"; import { WS_URL, ENABLED_TABS } from "./lib/constants"; import type { PacketDetail } from "./types/api"; @@ -44,7 +45,7 @@ const StatsOverview = lazy(() => import("./features/stats/StatsOverview").then(( const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: 2, + retry: shouldRetryQuery, refetchOnWindowFocus: false, }, }, diff --git a/src/api/client.ts b/src/api/client.ts index 3e29476..759806b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; +import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit"; import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; @@ -26,15 +27,25 @@ export type IataBorder = Feature; class ApiError extends Error { status: number; code: string; + retryAfterMs?: number; - constructor(status: number, code: string, message: string) { + constructor(status: number, code: string, message: string, retryAfterMs?: number) { super(message); this.name = "ApiError"; this.status = status; this.code = code; + this.retryAfterMs = retryAfterMs; } } +// Builds the error for a non-ok response; a 429 also flips the app-wide rate-limited flag. +async function errorFrom(res: Response): Promise { + const body = await res.json().catch(() => ({ error: { code: "unknown", message: res.statusText } })); + const retryAfterMs = parseRetryAfter(res.headers.get("Retry-After")); + if (res.status === 429) noteRateLimited(retryAfterMs); + return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs); +} + async function request(path: string, params?: Record): Promise { const url = new URL(`${API_BASE}${path}`, window.location.origin); if (params) { @@ -47,10 +58,8 @@ async function request(path: string, params?: Record ({ error: { code: "unknown", message: res.statusText } })); - throw new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText); - } + if (!res.ok) throw await errorFrom(res); + noteRequestOk(); return res.json(); } @@ -89,8 +98,9 @@ export function getIatas(): Promise { export async function getIataBorder(iata: string): Promise { const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin); const res = await fetch(url.toString()); + if (!res.ok) throw await errorFrom(res); + noteRequestOk(); if (res.status === 204) return null; - if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText); const body = await res.json(); return (body ?? null) as IataBorder | null; } diff --git a/src/api/rate-limit.ts b/src/api/rate-limit.ts new file mode 100644 index 0000000..40e6629 --- /dev/null +++ b/src/api/rate-limit.ts @@ -0,0 +1,64 @@ +import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../lib/constants"; + +// Kept free of client.ts imports so tests that mock the client with partial factories stay valid. + +// Retry-After as milliseconds. The server sends delta-seconds; the HTTP-date form is legal too. +export function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + const value = header.trim(); + let ms: number; + if (/^\d+$/.test(value)) { + ms = Number(value) * 1000; + } else { + const at = Date.parse(value); + if (Number.isNaN(at)) return undefined; + ms = at - Date.now(); + } + return Math.min(Math.max(ms, 0), RATE_LIMIT_MAX_MS); +} + +// TanStack Query retry predicate: a 4xx won't improve by asking again, and retrying a 429 is exactly +// what the server just asked us not to do. Two retries for everything else, as before. +export function shouldRetryQuery(failureCount: number, error: unknown): boolean { + if (failureCount >= 2) return false; + const status = (error as { status?: unknown } | null)?.status; + if (typeof status === "number" && status >= 400 && status < 500) return false; + return true; +} + +// App-wide "the API is throttling us" flag: fed by the fetch wrapper, read by the header badge. + +let limitedUntil: number | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + for (const listener of listeners) listener(); +} + +export function noteRateLimited(retryAfterMs: number = RATE_LIMIT_DEFAULT_MS): void { + const until = Date.now() + retryAfterMs; + if (limitedUntil !== null && until <= limitedUntil) return; + limitedUntil = until; + notify(); +} + +export function noteRequestOk(): void { + if (limitedUntil === null) return; + limitedUntil = null; + notify(); +} + +export function getRateLimitedUntil(): number | null { + return limitedUntil; +} + +export function isRateLimited(): boolean { + return limitedUntil !== null && Date.now() < limitedUntil; +} + +export function subscribeRateLimit(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index ffda8b4..f60eaeb 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -4,8 +4,12 @@ import { WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS, WS_RECONNECT_JITTER, + WS_STABLE_MS, } from "../lib/constants"; +// attempt index at which the backoff first reaches WS_RECONNECT_MAX_MS +const CAP_ATTEMPT = Math.ceil(Math.log2(WS_RECONNECT_MAX_MS / WS_RECONNECT_BASE_MS)); + // handler types and status export type WsStatus = "connected" | "connecting" | "disconnected" | "error"; @@ -29,6 +33,7 @@ export class WsManager { private everConnected = false; private status: WsStatus = "disconnected"; private reconnectAttempt = 0; + private openedAt: number | null = null; private intentionalClose = false; private reconnectTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; @@ -129,6 +134,7 @@ export class WsManager { disconnect(): void { this.intentionalClose = true; this.reconnectAttempt = 0; + this.openedAt = null; this.clearTimers(); this.teardownSocket(); this.subscriptionId = null; @@ -150,7 +156,7 @@ export class WsManager { this.ws = new WebSocket(this.url); this.ws.onopen = () => { - this.reconnectAttempt = 0; + this.openedAt = Date.now(); }; this.ws.onmessage = (e: MessageEvent) => { @@ -164,14 +170,14 @@ export class WsManager { this.handleMessage(msg); }; - this.ws.onclose = () => { + this.ws.onclose = (e: CloseEvent) => { this.clearTimers(); if (this.intentionalClose) { this.setStatus("disconnected"); return; } // any unexpected close — including a server-sent 1000 — gets a reconnect - this.scheduleReconnect(); + this.scheduleReconnect(e.code); }; this.ws.onerror = () => { @@ -286,8 +292,13 @@ export class WsManager { this.scheduleReconnect(); } - private scheduleReconnect(): void { + private scheduleReconnect(closeCode?: number): void { this.setStatus("connecting"); + // only a link that actually held resets the backoff — an accept-then-close must keep escalating + if (this.openedAt !== null && Date.now() - this.openedAt >= WS_STABLE_MS) this.reconnectAttempt = 0; + this.openedAt = null; + // 1008 policy / 1013 try-again-later mean the server is shedding us: go straight to the longest wait + if (closeCode === 1008 || closeCode === 1013) this.reconnectAttempt = Math.max(this.reconnectAttempt, CAP_ATTEMPT); const base = Math.min(WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, WS_RECONNECT_MAX_MS); const jitter = base * WS_RECONNECT_JITTER * (Math.random() * 2 - 1); const delay = Math.max(base + jitter, 100); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index bcff0da..bd70ba5 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useRegionSelection, useRegions } from "../hooks/useRegion"; import { ALL_REGIONS, isAllRegions, type RegionSelection } from "../hooks/region-selection"; import { useWsStatus } from "../hooks/useWsStatus"; +import { useRateLimit } from "../hooks/useRateLimit"; import { useTheme } from "../hooks/useTheme"; import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; @@ -53,6 +54,31 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { ); } +// Shown while the API is throttling us; the countdown tells users the blank tables are temporary. +function RateLimitBadge() { + const until = useRateLimit(); + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (until === null) return; + const end = until; + function update() { + setRemaining(Math.ceil((end - Date.now()) / 1000)); + } + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [until]); + + if (until === null || remaining <= 0) return null; + + return ( +
+ RATE LIMITED {remaining}s +
+ ); +} + // checkbox indicator, matching MultiSelectDropdown's style function CheckBox({ checked }: { checked: boolean }) { return ( @@ -336,6 +362,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe + { setLaggedCount((prev) => prev + data.droppedCount); - queryClient.resetQueries({ queryKey: ["packets", regionKey] }); + // while the API is throttling us the refetch would only 429; the next lag notice or remount heals it + if (!isRateLimited()) queryClient.resetQueries({ queryKey: ["packets", regionKey] }); }, [queryClient, regionKey], ); diff --git a/src/hooks/useRateLimit.ts b/src/hooks/useRateLimit.ts new file mode 100644 index 0000000..8f7388b --- /dev/null +++ b/src/hooks/useRateLimit.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; +import { getRateLimitedUntil, subscribeRateLimit } from "../api/rate-limit"; + +// epoch ms until which the API is throttling us, or null +export function useRateLimit(): number | null { + return useSyncExternalStore(subscribeRateLimit, getRateLimitedUntil); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ed4cab8..9783749 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -14,6 +14,12 @@ export const WS_PING_INTERVAL_MS = 30_000; export const WS_RECONNECT_BASE_MS = 1000; export const WS_RECONNECT_MAX_MS = 30_000; export const WS_RECONNECT_JITTER = 0.25; +// a link that held this long counts as healthy, so the next drop restarts the backoff from scratch +export const WS_STABLE_MS = 10_000; + +// window to assume when a 429 arrives without Retry-After, and a ceiling on what the header can ask +export const RATE_LIMIT_DEFAULT_MS = 10_000; +export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 207a36d..d5e3787 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -5,6 +5,8 @@ import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; import type { KnownRoute, TraceTagSummary, TraceDetail } from "../../src/types/api"; +import { getRateLimitedUntil, noteRequestOk } from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS } from "../../src/lib/constants"; // Capture the URL the client fetches and hand back a canned CursorPage. function mockFetchOnce(body: unknown): () => string { @@ -21,6 +23,8 @@ function mockFetchOnce(body: unknown): () => string { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); + noteRequestOk(); }); describe("getPackets", () => { @@ -449,6 +453,8 @@ describe("getIataBorder", () => { return { ok: status >= 200 && status < 300, status, + statusText: "status", + headers: new Headers(), json: async () => { if (status === 204) throw new Error("no body to parse"); return body; @@ -485,4 +491,71 @@ describe("getIataBorder", () => { mockStatus(200, feature); await expect(getIataBorder("YOW")).resolves.toEqual(feature); }); + + it("surfaces a 429 with the same ApiError shape as request()", async () => { + mockStatus(429, { error: { code: "rate_limited", message: "slow down" } }); + const err = await getIataBorder("YOW").catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited" }); + }); +}); + +describe("error responses", () => { + function mockError(status: number, body: unknown, headers: Record = {}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status, + statusText: "status", + headers: new Headers(headers), + json: async () => body, + }) as unknown as Response), + ); + } + const limited = { error: { code: "rate_limited", message: "slow down" } }; + + it("a 429 throws an ApiError carrying status, code and retryAfterMs from the header", async () => { + mockError(429, limited, { "Retry-After": "12" }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited", retryAfterMs: 12_000 }); + }); + + it("a 429 without Retry-After leaves retryAfterMs undefined", async () => { + mockError(429, limited); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 429, retryAfterMs: undefined }); + }); + + it("a 429 marks the app rate-limited for Retry-After", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited, { "Retry-After": "12" }); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_012_000); + }); + + it("a 429 without the header uses the default window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("the next successful request clears the rate-limited state", async () => { + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).not.toBeNull(); + + mockFetchOnce([]); + await getScopes(); + expect(getRateLimitedUntil()).toBeNull(); + }); + + it("a plain 500 is not treated as rate limiting", async () => { + mockError(500, { error: { code: "internal", message: "boom" } }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 500, code: "internal" }); + expect(getRateLimitedUntil()).toBeNull(); + }); }); diff --git a/tests/api/rate-limit.test.ts b/tests/api/rate-limit.test.ts new file mode 100644 index 0000000..52160fe --- /dev/null +++ b/tests/api/rate-limit.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseRetryAfter, + shouldRetryQuery, + noteRateLimited, + noteRequestOk, + getRateLimitedUntil, + isRateLimited, + subscribeRateLimit, +} from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../../src/lib/constants"; + +afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); +}); + +describe("parseRetryAfter", () => { + it("reads delta-seconds as milliseconds", () => { + expect(parseRetryAfter("7")).toBe(7_000); + }); + + it("reads an HTTP-date relative to now", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T12:00:00Z")); + expect(parseRetryAfter("Wed, 02 Sep 2026 12:00:30 GMT")).toBe(30_000); + }); + + it("returns undefined for a missing or junk header", () => { + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter("soon")).toBeUndefined(); + }); + + it("clamps absurd values to the ceiling", () => { + expect(parseRetryAfter("9999")).toBe(RATE_LIMIT_MAX_MS); + }); +}); + +describe("shouldRetryQuery", () => { + it("never retries a 429", () => { + expect(shouldRetryQuery(0, { status: 429 })).toBe(false); + }); + + it("never retries a 404", () => { + expect(shouldRetryQuery(0, { status: 404 })).toBe(false); + }); + + it("retries a 5xx twice then stops", () => { + expect(shouldRetryQuery(0, { status: 500 })).toBe(true); + expect(shouldRetryQuery(1, { status: 500 })).toBe(true); + expect(shouldRetryQuery(2, { status: 500 })).toBe(false); + }); + + it("retries a network error", () => { + expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); + }); +}); + +describe("rate-limit store", () => { + it("starts un-limited", () => { + expect(getRateLimitedUntil()).toBeNull(); + expect(isRateLimited()).toBe(false); + }); + + it("noteRateLimited opens the window and notifies subscribers", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRateLimited(5_000); + + expect(getRateLimitedUntil()).toBe(1_005_000); + expect(isRateLimited()).toBe(true); + expect(cb).toHaveBeenCalledOnce(); + unsub(); + }); + + it("uses the default window when no Retry-After was given", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + noteRateLimited(); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("is no longer limited once the window elapses", () => { + vi.useFakeTimers(); + noteRateLimited(5_000); + vi.advanceTimersByTime(5_000); + expect(isRateLimited()).toBe(false); + }); + + it("a longer window extends, a shorter one does not shrink", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + noteRateLimited(5_000); + noteRateLimited(10_000); + expect(getRateLimitedUntil()).toBe(10_000); + noteRateLimited(2_000); + expect(getRateLimitedUntil()).toBe(10_000); + }); + + it("noteRequestOk clears and notifies, and stays quiet when nothing was set", () => { + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRequestOk(); + expect(cb).not.toHaveBeenCalled(); + + noteRateLimited(5_000); + noteRequestOk(); + expect(getRateLimitedUntil()).toBeNull(); + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + }); +}); diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index 913b62c..e6d8945 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -404,3 +404,72 @@ describe("WsManager", () => { expect(handler).not.toHaveBeenCalled(); }); }); + +describe("WsManager reconnect backoff", () => { + // Math.random = 0.5 zeroes the jitter so delays are exact + function connectOnce() { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + return mgr; + } + + it("keeps escalating when the server accepts then closes at once (no 1 s treadmill)", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + + const ws3 = MockWebSocket.instances[2]!; + ws3.simulateOpen(); + ws3.simulateClose(1006); + vi.advanceTimersByTime(3999); + expect(MockWebSocket.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(4); + }); + + it("resets the backoff after a link that held for WS_STABLE_MS", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + vi.advanceTimersByTime(10_000); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(3); + }); + + it("jumps to and stays at the max backoff on a 1013 shed close", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1013); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + }); +}); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index 435e682..e3c1456 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -1,11 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "../../src/components/AppShell"; import { RegionProvider } from "../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../src/hooks/region-selection"; import { getIatas, getRegions, getRegion } from "../../src/api/client"; import type { WsManager } from "../../src/api/ws-manager"; +import { noteRateLimited, noteRequestOk } from "../../src/api/rate-limit"; import pkg from "../../package.json"; vi.mock("../../src/api/client", () => ({ @@ -219,3 +220,43 @@ describe("region picker filter", () => { expect(screen.getByText("Eastern Canada")).toBeInTheDocument(); }); }); + +describe("rate limit badge", () => { + beforeEach(() => { + vi.mocked(getIatas).mockResolvedValue([]); + vi.useFakeTimers(); + }); + + afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); + }); + + it("is hidden by default", () => { + renderShell(); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("shows a countdown after a 429 and hides once Retry-After elapses", () => { + renderShell(); + + act(() => noteRateLimited(5_000)); + expect(screen.getByText(/RATE LIMITED 5s/)).toHaveAttribute("role", "status"); + + act(() => vi.advanceTimersByTime(2_000)); + expect(screen.getByText(/RATE LIMITED 3s/)).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(3_000)); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("clears on the next successful request", () => { + renderShell(); + + act(() => noteRateLimited(30_000)); + expect(screen.getByText(/RATE LIMITED/)).toBeInTheDocument(); + + act(() => noteRequestOk()); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index 5e7640a..bb07f8c 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -6,6 +6,7 @@ import { usePackets } from "../../../src/features/packets/usePackets"; import type { PacketServerFilter } from "../../../src/features/packets/types"; import type { PacketSummary } from "../../../src/types/api"; import type { WsPacketObservation } from "../../../src/types/ws"; +import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit"; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), @@ -85,6 +86,23 @@ describe("usePackets gap healing", () => { expect(getPackets).toHaveBeenCalledTimes(1); expect(result.current.laggedCount).toBe(5); }); + + it("skips the lag reset while the API is rate-limiting us, but still counts the drop", async () => { + const { result } = renderHook(() => usePackets(), { wrapper }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + seedThreePages(qc); + getPackets.mockClear(); + noteRateLimited(10_000); + act(() => { + result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 }); + }); + noteRequestOk(); + + expect(getPackets).not.toHaveBeenCalled(); + expect(qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"])?.pages).toHaveLength(3); + expect(result.current.laggedCount).toBe(5); + }); }); describe("usePackets server filter", () => {