API Error Card
An LLM API failure card whose recovery is chosen by error class — a countdown ring with one-shot opt-in auto-retry for 429s, a key-settings exit for auth, a status-page exit for overload, and trim / summarize exits when the context is too long.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/api-error-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ApiErrorCard" component (lucide-react is
the only dependency). It turns one failed LLM API call into the recovery that
actually works for THAT class of failure.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; the root spreads the rest.
- kind: "rate_limited" | "auth" | "overloaded" | "server" | "context_length" |
"timeout" | "unknown" — the only required prop.
- Export classifyApiError(status?: number, code?: string): ApiErrorKind. Provider
code strings win over the HTTP status (a context overflow arrives as 400 +
context_length_exceeded, and a bare 400 is indistinguishable from a malformed
request); then 401/403 -> auth, 408/504 -> timeout, 413 -> context_length,
429 -> rate_limited, 503/529 -> overloaded, any other 5xx -> server, else
unknown. Export it so a provider that disagrees can replace it.
- title?, hint?: ReactNode, message?: string (the provider's own text, verbatim),
status?: number, code?: string, requestId?: string, model?: string.
- attempt?: number = 1 — 1-based count of CONSECUTIVE failures of this request.
- retryAt?: Date | number | string — the ABSOLUTE instant the retry window opens.
Do not accept Retry-After seconds: they are only true at the moment the
response was produced, and only the caller knows when that was
(Date.now() + s * 1000). Accepting seconds silently restarts the wait on every
remount.
- now?: Date | number | string — reference instant for the first paint only, so
the server and the first client frame print identical digits.
- onRetry?: () => void, retrying?: boolean (consumer-owned pending flag),
retryable?: boolean (override the class default).
- autoRetry?: boolean / defaultAutoRetry?: boolean / onAutoRetryChange? —
controlled + uncontrolled toggle; allowAutoRetry?: boolean = true;
maxAutoRetries?: number = 3.
- slots?: Partial<Record<"settings" | "status" | "trim" | "summarize",
ApiErrorAction>> plus actions?: ApiErrorAction[] for anything extra.
ApiErrorAction = { label; href } | { label; onClick } — a union, never both
optional, so a button that goes nowhere is a compile error rather than a
support ticket. Pass ONE slots object to every error: each class renders only
the slots it asks for and ignores the rest.
- onDismiss?: () => void — the dismiss affordance exists only if this exists.
- labels?: Partial<ApiErrorCardLabels> — every string, including the ring's
spoken label and the three announcements.
Behavior — the class decides, not the JSX
- One RECIPES lookup keyed by kind holds: title, the "what will NOT fix this"
hint, an icon, `retryable`, and the ordered slot ids. Adding a class is one row
in that table, never another branch in the markup.
- retryable is false for auth and context_length: passing onRetry to them renders
NO retry button, because sending the same key or the same oversized payload
reproduces the error exactly. This is the whole point of the component — say so
in the hint instead of offering a button that is guaranteed to fail.
- When there is no retry button the class's first available slot becomes the
emphasised (primary-filled) action; a card with nothing emphasised reads as a
dead end. Slots with no handler are dropped, never rendered inert.
Behavior — the countdown
- A ring renders only when retry is enabled AND retryAt parses. Non-finite dates
mean "no wait given", never "wait forever".
- Every tick recomputes target - Date.now() (250 ms) and never decrements a
previous value: a background tab is throttled to roughly one timer per minute,
so a decrementing clock returns from a five-minute sleep five minutes wrong.
Re-measure on visibilitychange too, so returning to the tab corrects instantly
instead of after the next tick.
- The ring's denominator is frozen when the cycle arms, so the arc is monotonic.
Draw an EMPTY ring while the remaining time is still unknown (the server
frame): unknown must not look like done.
- Label budget is ~4 glyphs and the unit comes from the WINDOW, not from what is
left: a 90 s wait that flips from "1:00" to "59s" halfway reads as a bug.
seconds < 60 -> "45s", window >= 60 -> "m:ss", over an hour -> "125m".
- Clear the interval, the frame and the visibilitychange listener on unmount and
on every re-arm. The request-id copy button owns one more timer (the 2 s
"copied" reset); clear that on unmount too, and swallow a rejected
clipboard write — an insecure context has no clipboard, and the id is still
selectable text.
Behavior — the one-shot lock (the rule that keeps a retry loop honest)
- A "cycle" is one failure the user may answer once. Its key is
kind | retryAt | attempt | requestId. Any change re-arms the lock, the clock
and the announcement together; re-arm during render (React's adjust-state
pattern) so the first frame after a new error never shows the old wait.
- At most ONE onRetry leaves the card per cycle, whether it came from the button
or from the automatic path. After it fires the button reads "Retry sent" and
stays inert until the consumer renders a new failure. Two retries per failure
is how a client turns one 429 into a stampede.
- The window opens exactly once per cycle no matter how many ticks observe zero
(an interval, a rAF and a visibilitychange handler can all land on the same
millisecond).
- Auto-retry is opt-in and fires exactly once, at zero. Turning it ON after the
window is already open must fire immediately — a toggle that promises a retry
and does nothing is worse than no toggle.
- Ceiling: when attempt >= maxAutoRetries the automatic path is off, the switch
is disabled and the card SAYS why ("Automatic retries stopped after 3
attempts"). The manual button stays live. An automatic retry with no ceiling is
a client-side DDoS with a nice UI.
- The switch renders only when a countdown exists: with nothing to wait for,
"retry automatically" is an unbounded loop.
Behavior — accessibility
- One always-mounted, initially EMPTY sr-only role="status" aria-live="polite"
region. A live region that already holds content when it enters the DOM is
unreliable, so fill it on the next frame. It carries three transitions only:
the error arriving, the window opening, and the automatic retry firing. The
ticking number NEVER enters it — a per-second live region shouts over the
reader.
- Give the ring role="img" with an aria-label spelled out in words ("2 minutes 5
seconds until this request can be sent again"), so the value is available on
demand without being announced.
- The retry button uses aria-disabled, not the native attribute: a natively
disabled button leaves the tab order, so a keyboard user parked on it loses
focus to <body> the moment the wait ends. Guard the action in the handler.
- The toggle is a real button with role="switch" + aria-checked, labelled by the
visible label and described by the hint under it.
- The retry label never contains the ticking number, so its accessible name is
stable while it is being announced.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground, border, bg-muted/40,
text-muted-foreground, bg-destructive/10 + text-destructive for the class
glyph and the streak chip, bg-primary / text-primary-foreground for the
primary action and the switch track, bg-input for the off track, bg-background
for the knob, ring for focus. No hex, no rgb(), no oklch().
- Merge every className through cn(); expose data-kind and data-state
("waiting" | "open") for consumers who style by state.
- The ring is an SVG circle pair (stroke-current + opacity for the track,
strokeDasharray / strokeDashoffset for the arc, -rotate-90 so it starts at
twelve o'clock). Transition stroke-dashoffset for exactly one tick so it reads
as motion rather than stutter, and drop the transition — plus the retry
spinner — under motion-reduce: the number alone carries the state.
- Long values must not widen anything: wrap-anywhere on the provider message,
break-all on ids and the status/code chip, truncate on the copyable request id,
min-w-0 on every flex child.
Customization levers
- The taxonomy: add or remove a kind by editing RECIPES (title, hint, icon,
retryable, slots). Replace classifyApiError wholesale for your provider.
- Slot vocabulary: rename or extend the slot ids and their icons; the map from
class to slots is data, so "auth -> billing" is a one-line change.
- Retry policy: retryable={true} to force the button onto a class that normally
suppresses it, allowAutoRetry={false} to drop the automatic path entirely,
maxAutoRetries to set the ceiling (0 or 1 disables auto-retry after the first
failure).
- Tick rate (250 ms) and ring geometry (radius 20, stroke 4, size-12): a larger
ring wants a longer label budget, a smaller one wants "m" units sooner.
- Density: drop the provider message, the meta row or the streak chip by simply
not passing message / model / requestId / attempt; the card has no empty slots.
- Copy and i18n: every string is in labels, including the ring's spoken label and
the three announcements; the duration inside that label is spelled out by a
small English helper, so a locale swap replaces that one function.
- Emphasis: the primary style follows "retry, else the first slot" — swap it if
your product wants the status page to lead on provider outages.Concepts
- Recovery recipe lookup — the failure class is the input and everything visible is read from one table: the headline, the "what will not fix this" sentence, whether a retry button exists at all, and which exit slots the card asks the app for. A new class is a row, not a branch.
- Retry suppressed when useless — auth and context-length failures render no retry, because the same key and the same oversized payload reproduce the error byte for byte; the class hands its exit (open key settings, trim, summarize) the primary emphasis instead, so the card never dead-ends on a button that is guaranteed to fail.
- One-shot lock per cycle — a cycle is one failure the user may answer once, keyed by class plus retry instant plus attempt plus request id; exactly one retry escapes it, from the button or the automatic path, and only a genuinely new failure re-arms the lock, the clock and the announcement together.
- Absolute-deadline countdown — the ring recomputes
target − nowon every tick and on every return to visibility rather than decrementing, so a throttled background tab, a locked laptop or a slow frame can never leave the number wrong; an unmeasured wait draws an empty ring, because unknown must not look like done. - Opt-in auto-retry with a ceiling — the automatic path fires exactly once at zero (and immediately if it is armed after the window already opened), then switches itself off once consecutive attempts reach the ceiling and says so in words; an automatic retry without a ceiling is a client-side stampede with good typography.
- Quiet live region — the always-mounted status region stays empty until the next frame and then speaks only three transitions — the error arriving, the window opening, the automatic retry firing — while the ticking seconds stay visual, reachable on demand through the ring's spoken label.
Assistant Welcome
A first-run assistant hero — greeting, capability cards and starter chips that prefill the host's composer instead of sending, over a token-only animated accent.
Stream Inspector
A developer feed of the raw frames of one streamed model response — per-frame offsets and gaps, a TTFT marker line, header stats, stall marking with jump-to-next, delta folding, tail-follow with break-away and a pretty/raw toggle per row.