From 2045f852ee70e3b083bde99e46e92cbbb7432e98 Mon Sep 17 00:00:00 2001 From: Noah Yakubu Date: Wed, 29 Jul 2026 01:12:02 +0100 Subject: [PATCH] feat(api): rate-limit feedback with retry countdown Closes #517 - Add src/lib/api.ts: central apiFetch wrapper that intercepts 429s, parses Retry-After header, schedules auto-retry via setTimeout, and notifies listeners; only one countdown tracked at a time regardless of concurrent 429s - Add src/context/RateLimitContext.tsx: RateLimitProvider subscribes to api.ts rate-limit events and exposes retryAt timestamp via useRateLimit hook - Add src/components/layout/RateLimitBar.tsx: slim dismissable amber banner with live second countdown; dismissing hides the banner but auto-retry still fires - Wire RateLimitProvider + RateLimitBar into app layout above Navbar --- src/app/layout.tsx | 39 +++++++------ src/components/layout/RateLimitBar.tsx | 78 +++++++++++++++++++++++++ src/context/RateLimitContext.tsx | 39 +++++++++++++ src/lib/api.ts | 79 ++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 src/components/layout/RateLimitBar.tsx create mode 100644 src/context/RateLimitContext.tsx create mode 100644 src/lib/api.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b9fee67..e470c89 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,6 +23,8 @@ import QueryProvider from "@/contexts/QueryProvider"; import LanguageSwitcher from "@/components/LanguageSwitcher"; import { UserPreferencesProvider } from "@/context/UserPreferencesContext"; import { FiatRateProvider } from "@/hooks/useFiatRate"; +import { RateLimitProvider } from "@/context/RateLimitContext"; +import RateLimitBar from "@/components/layout/RateLimitBar"; const themeBootstrap = ` (function () { @@ -184,23 +186,26 @@ export default function RootLayout({ - - - - {children} -
-
-

- © {new Date().getFullYear()} StellarSplit -

- -
-
- - - - - + + + + + + {children} +
+
+

+ © {new Date().getFullYear()} StellarSplit +

+ +
+
+ + + + + +
diff --git a/src/components/layout/RateLimitBar.tsx b/src/components/layout/RateLimitBar.tsx new file mode 100644 index 0000000..079d712 --- /dev/null +++ b/src/components/layout/RateLimitBar.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRateLimit } from "@/context/RateLimitContext"; + +/** + * RateLimitBar — a slim dismissable banner that appears at the top of the page + * whenever an API call returns 429. Shows a live countdown and disappears when + * the auto-retry resolves. + * + * Dismissing hides the visual banner but the auto-retry still proceeds. + */ +export default function RateLimitBar() { + const { retryAt } = useRateLimit(); + const [dismissed, setDismissed] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(0); + + // Reset dismissed state each time a new rate-limit is triggered. + useEffect(() => { + if (retryAt !== null) { + setDismissed(false); + setSecondsLeft(Math.max(0, Math.ceil((retryAt - Date.now()) / 1_000))); + } + }, [retryAt]); + + // Tick the countdown every second while active. + useEffect(() => { + if (retryAt === null) return; + + const tick = () => { + const s = Math.max(0, Math.ceil((retryAt - Date.now()) / 1_000)); + setSecondsLeft(s); + }; + + tick(); + const id = setInterval(tick, 1_000); + return () => clearInterval(id); + }, [retryAt]); + + // Nothing to render when there's no rate-limit or it's been dismissed. + if (retryAt === null || dismissed) return null; + + return ( +
+ + Rate limit reached — retrying automatically in{" "} + {secondsLeft}s + {secondsLeft === 0 && " (retrying…)"} + + + +
+ ); +} diff --git a/src/context/RateLimitContext.tsx b/src/context/RateLimitContext.tsx new file mode 100644 index 0000000..1e217de --- /dev/null +++ b/src/context/RateLimitContext.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { + subscribeRateLimit, + getCurrentRateLimitRetryAt, +} from "@/lib/api"; + +interface RateLimitContextValue { + /** Unix ms timestamp when the retry will fire, or null when not rate-limited. */ + retryAt: number | null; +} + +const RateLimitContext = createContext({ retryAt: null }); + +export function RateLimitProvider({ children }: { children: ReactNode }) { + const [retryAt, setRetryAt] = useState(getCurrentRateLimitRetryAt); + + useEffect(() => { + const unsub = subscribeRateLimit(setRetryAt); + return unsub; + }, []); + + return ( + + {children} + + ); +} + +export function useRateLimit(): RateLimitContextValue { + return useContext(RateLimitContext); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..dbe96ce --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,79 @@ +/** + * Central fetch wrapper that intercepts 429 (Too Many Requests) responses, + * reads the Retry-After header, and schedules an automatic retry. + * + * Consumers call `apiFetch` instead of raw `fetch`. When a 429 is received: + * - The global RateLimitContext is notified so the banner renders a countdown. + * - The request is queued and retried automatically after the delay. + * - Only one pending rate-limit state is tracked at a time; concurrent 429s + * do not duplicate the banner. + */ + +type RateLimitListener = (retryAt: number | null) => void; + +const listeners = new Set(); +let currentRetryAt: number | null = null; + +/** Subscribe to rate-limit state changes (used by RateLimitContext). */ +export function subscribeRateLimit(fn: RateLimitListener): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +/** Read the current retryAt timestamp (ms epoch), or null if not rate-limited. */ +export function getCurrentRateLimitRetryAt(): number | null { + return currentRetryAt; +} + +function notify(retryAt: number | null) { + currentRetryAt = retryAt; + listeners.forEach((fn) => fn(retryAt)); +} + +/** + * Parse the Retry-After header value (seconds or HTTP-date) into milliseconds + * from now. Falls back to 60 s if the header is absent or unparseable. + */ +function parseRetryAfter(header: string | null): number { + if (!header) return 60_000; + const seconds = Number(header); + if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1_000; + const date = new Date(header); + if (!Number.isNaN(date.getTime())) { + return Math.max(0, date.getTime() - Date.now()); + } + return 60_000; +} + +/** + * `apiFetch` is a drop-in replacement for `fetch` that adds transparent + * 429-retry with countdown notification. + * + * - Retries the request **once** after the Retry-After delay. + * - Resolves the original Promise as if the first call had succeeded. + * - Multiple concurrent 429s share one countdown; the banner stays single. + */ +export async function apiFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const response = await fetch(input, init); + + if (response.status !== 429) return response; + + const delayMs = parseRetryAfter(response.headers.get("Retry-After")); + const retryAt = Date.now() + delayMs; + + // Only update state if no countdown is already running or this one is later. + if (currentRetryAt === null || retryAt > currentRetryAt) { + notify(retryAt); + } + + // Wait for the retry window, then re-issue the request. + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + // Clear rate-limit state after retry fires. + notify(null); + + return fetch(input, init); +}