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).
| code | Meaning | The one action |
|---|---|---|
| not_connected | No budget connected yet | Show “Connect AI budget” |
| funding_invalid | Their funding key broke | Show “Reconnect” (same button) |
| budget_exceeded | Monthly cap reached | Show usage; link to raise the cap |
| rate_limited | Too many requests | Back off / “slow down” message |
| network / upstream | Transient failure | Retry with backoff |
| auth | Your credentials are wrong | Log & 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.
// 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.
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.
// 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:
// 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;
}