AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Activity,
  ArrowUpRight,
  Check,
  CircleAlert,
  Copy,
  Gauge,
  KeyRound,
  Layers,
  RotateCw,
  Scissors,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/api-error-card.json

Prompt

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 − now on 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.

On This Page