Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/components/layout/RateLimitBar.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(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 (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="relative z-50 flex items-center justify-between gap-3 bg-amber-500 text-amber-950 px-4 py-2 text-sm font-medium"
>
<span>
Rate limit reached — retrying automatically in{" "}
<strong>{secondsLeft}s</strong>
{secondsLeft === 0 && " (retrying…)"}
</span>

<button
onClick={() => setDismissed(true)}
aria-label="Dismiss rate-limit notice"
className="shrink-0 flex items-center justify-center h-6 w-6 rounded hover:bg-amber-600/20 transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden="true"
>
<path
d="M1 1l12 12M13 1 1 13"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}
39 changes: 39 additions & 0 deletions src/context/RateLimitContext.tsx
Original file line number Diff line number Diff line change
@@ -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<RateLimitContextValue>({ retryAt: null });

export function RateLimitProvider({ children }: { children: ReactNode }) {
const [retryAt, setRetryAt] = useState<number | null>(getCurrentRateLimitRetryAt);

useEffect(() => {
const unsub = subscribeRateLimit(setRetryAt);
return unsub;
}, []);

return (
<RateLimitContext.Provider value={{ retryAt }}>
{children}
</RateLimitContext.Provider>
);
}

export function useRateLimit(): RateLimitContextValue {
return useContext(RateLimitContext);
}
79 changes: 79 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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<RateLimitListener>();
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<Response> {
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<void>((resolve) => setTimeout(resolve, delayMs));

// Clear rate-limit state after retry fires.
notify(null);

return fetch(input, init);
}
Loading