Wattfare / Docs
Dashboard
Guides

Error recovery

Every failure mode maps to a specific, recoverable piece of UI. This guide turns Wattfare's typed errors into states your users actually understand — connect, top up, slow down, or retry.

The principle

Don't surface raw errors. Each Wattfare error code corresponds to exactly one user action. Your job is to translate code → action, on the server first (so the browser gets a stable code) and in the UI second (so the user gets a button).

codeMeaningThe one action
not_connectedNo budget connected yetShow “Connect AI budget”
funding_invalidTheir funding key brokeShow “Reconnect” (same button)
budget_exceededMonthly cap reachedShow usage; link to raise the cap
rate_limitedToo many requestsBack off / “slow down” message
network / upstreamTransient failureRetry with backoff
authYour credentials are wrongLog & alert — it's your bug, not theirs

Step 1 — normalise on the server

Recover the typed error and re-emit its JSON, so the browser always receives { error: { code, message } } with a meaningful status instead of a generic 500.

server
// app/api/chat/route.ts — translate every failure into a stable client code.
import { toWattfareError } from "wattfare/server";

try {
  const result = streamText({ model: ai.model(MODEL), messages });
  return result.toTextStreamResponse();
} catch (err) {
  const wf = toWattfareError(err);
  if (!wf) throw err; // not ours — let the platform 500 it

  // Re-emit the typed error so the browser gets a code it can switch on.
  return Response.json(wf.toJSON(), { status: wf.status || 502 });
}

Step 2 — switch in the UI

Read the code and move to a named state. No string-matching on messages.

client
async function send(prompt: string) {
  const res = await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ prompt }),
  });

  if (!res.ok) {
    const { error } = await res.json();
    switch (error?.code) {
      case "not_connected":
      case "funding_invalid":
        return setState({ kind: "needs_connect" });
      case "budget_exceeded":
        return setState({ kind: "out_of_budget" });
      case "rate_limited":
        return setState({ kind: "slow_down" });
      default:
        return setState({ kind: "error", message: error?.message });
    }
  }

  // …stream the successful response
}

Connect vs. reconnect

not_connected and funding_invalid both mean “the user must act before this works,” which is why isNotConnected() matches both. The same connect button fixes both — only the copy changes.

needs-connect state
// funding_invalid means the *user's* funding broke (revoked key, no credit).
// It's caught by isNotConnected, so the same "Connect" button fixes it —
// just lead with a clearer message.
const { connect } = useWattfare();

if (state.kind === "needs_connect") {
  return (
    <div>
      <p>Reconnect your AI budget to continue.</p>
      <button onClick={connect}>⚡ Connect AI budget</button>
    </div>
  );
}

Out of budget

On budget_exceeded, show the user where they stand and where to fix it. Pull the numbers from status.usage.monthlyUsd and status.limits.monthlyUsd, and link them to their budget page to raise the cap.

Retrying transient failures

Only rate_limited, network, and upstream are worth retrying — the rest need a human. Back off exponentially with a little jitter:

withRetry
// Exponential backoff for transient failures (network / upstream / rate limit).
async function withRetry<T>(fn: () => Promise<T>, tries = 3): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < tries; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      const wf = toWattfareError(err);
      const retryable = wf?.code === "rate_limited" || wf?.code === "network" || wf?.code === "upstream";
      if (!retryable) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 400 + Math.random() * 200));
    }
  }
  throw lastErr;
}