Handle API rate limiting in the client and back off WS reconnects

This commit is contained in:
MrAlders0n committed 2026-09-02 19:50:35 -04:00
1 parent d0aa7f9c8e
commit 276ef903fb
13 files changed
+459 -14

No files matched your search

+2 -1
View File
@@ -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,
},
},
+16 -6
View File
@@ -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<Polygon | MultiPolygon>;
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<ApiError> {
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<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(`${API_BASE}${path}`, window.location.origin);
if (params) {
@@ -47,10 +58,8 @@ async function request<T>(path: string, params?: Record<string, string | number
const res = await fetch(url.toString());
if (!res.ok) {
const body = await res.json().catch(() => ({ 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<IataCode[]> {
export async function getIataBorder(iata: string): Promise<IataBorder | null> {
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;
}
+64
View File
@@ -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);
};
}
+15 -4
View File
@@ -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<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | 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);
+27
View File
@@ -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 (
<div role="status" className="flex items-center gap-1.5 font-mono text-[11px] text-warn bg-warn/7 border border-warn/15 px-2 py-0.5 rounded-sm">
RATE LIMITED {remaining}s
</div>
);
}
// checkbox indicator, matching MultiSelectDropdown's style
function CheckBox({ checked }: { checked: boolean }) {
return (
@@ -336,6 +362,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
<RegionSelector />
<ThemePicker />
<LiveBadge wsManager={wsManager} />
<RateLimitBadge />
<a
href={GITHUB_URL}
target="_blank"
+3 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from "react";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { getPackets } from "../../api/client";
import { isRateLimited } from "../../api/rate-limit";
import { useRegion } from "../../hooks/useRegion";
import type { WsPacketObservation, WsLagged } from "../../types/ws";
import type { PacketSummary } from "../../types/api";
@@ -185,7 +186,8 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
const handleLagged = useCallback(
(data: WsLagged) => {
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],
);
+7
View File
@@ -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);
}
+6
View File
@@ -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;
+73
View File
@@ -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<string, string> = {}) {
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();
});
});
+116
View File
@@ -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();
});
});
+69
View File
@@ -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);
});
});
+43 -2
View File
@@ -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();
});
});
@@ -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", () => {