Error, refusal and empty states
Design the unhappy paths of AI features, from API errors and refusals to truncated answers, empty results and timeouts, with copy and recovery for each.
On this page
Why this matters
Users remember how a product behaves when things go wrong. AI features go wrong in more ways than most: the model can be slow, cut off mid-answer, decline a request, return nothing useful, or the API can be busy or unreachable. Each of those is a moment users experience directly.
When these paths aren’t designed, they show up as blank boxes, spinners that never stop, half-finished answers presented as complete, or raw error codes. When they are designed, a failure becomes a small detour with a clear way forward. This article lists every unhappy path, shows how to write copy for each, and gives you a typed mapping you can drop into your code.
The full list of unhappy paths
Every AI request ends in one of a small number of ways. Here are the unhappy ones, what causes them and how often you can do something about them:
| State | Cause | Usually recoverable by |
|---|---|---|
| Truncated | stop_reason is max_tokens or the context window filled |
Asking for a shorter answer, continuing, or splitting the task |
| Declined | stop_reason is refusal |
Rephrasing, if the request was benign; otherwise a manual path |
| Empty or unhelpful | The model answered, but there’s nothing useful: no results, no relevant source | A different query, more context, or a non-AI path |
| Rate limited | API returned 429 | Waiting and retrying, ideally automatically |
| Service error | API returned a 5xx error, or overloaded | Retrying after a short wait |
| Network error | Connection failed or dropped mid-stream | Retrying |
| Timeout | Your own time limit was reached | Retrying, or moving the task to the background |
| Invalid request | A bug: bad parameters, input too large | Fixing the code; the user can’t help |
| Stopped | The user pressed Stop | Using the partial output or regenerating |
The first three come from the model’s response, and are covered in detail in the first article’s code example. The rest come from the API or your own infrastructure; the Anthropic SDK (opens in a new tab) raises typed errors for each, so you can tell them apart without parsing messages.
Writing copy for each state
Good error copy answers three questions, in this order:
Show text descriptionHide text description
3 parts, in order:
- What happened: In plain words, without jargon or blame Example: This reply was cut off before the end.
- Why, if it helps: Only when the reason helps the user act Example: It ran longer than the length limit.
- What to do next: A specific action, ideally one click Example: Generate a shorter reply · Keep this draft
Some guidelines:
- Plain language, no codes. “The assistant is busy right now” beats “Error 529: overloaded”. Log the code; show the meaning.
- Don’t blame the user. “We couldn’t draft this” rather than “Your request was invalid”.
- Be specific about what’s lost and what’s kept. “Your draft is saved” removes anxiety.
- Refusals deserve care. Users who hit a refusal for a benign request feel accused. Keep the tone neutral, don’t lecture, and offer a path: rephrase, or do it manually. Don’t expose internal policy details.
- Empty is not an error. “No matching help articles” with a link to search or contact support is a normal state; design it as one.
Recovery actions
The best unhappy state ends with a button that fixes it. Typical recovery actions:
- Retry, for transient problems. Retry automatically, with a short backoff, before showing anything; the SDK already retries rate limits and server errors a couple of times by default. Show “Try again” only once automatic retries are exhausted.
- Shorter or narrower, for truncation: regenerate with a request for a shorter answer, or split the task.
- Continue, for truncation, when partial output is useful: ask the model to continue from where it stopped.
- Rephrase, for refusals of benign requests and for unhelpful answers.
- Do it manually, always. Every AI feature should leave a path to the non-AI way of doing the job.
Never offer “Try again” for problems retrying can’t fix: invalid requests, policy refusals, or content that’s too large.
A typed state table
Here’s one place to keep all of this: a typed table mapping every outcome to its copy and recovery actions. Because the outcome type is a union and the table is a Record over it, TypeScript refuses to compile if you add an outcome without designing its state.
import Anthropic from '@anthropic-ai/sdk';
/** Every way an AI request can end, from the user's point of view. */
export type Outcome =
| 'ready'
| 'truncated'
| 'declined'
| 'empty'
| 'busy'
| 'unavailable'
| 'offline'
| 'timeout'
| 'bug'
| 'stopped';
export type Action = 'insert' | 'shorter' | 'continue' | 'rephrase' | 'retry' | 'manual' | 'keep';
export interface UiState {
message: string;
actions: Action[];
}
/** Copy and recovery actions for each outcome. A missing outcome is a compile error. */
export const states: Record<Outcome, UiState> = {
ready: { message: 'Draft ready.', actions: ['insert'] },
truncated: { message: 'This draft was cut off before the end.', actions: ['shorter', 'continue', 'keep'] },
declined: { message: "This one can't be drafted automatically.", actions: ['rephrase', 'manual'] },
empty: { message: 'No matching help articles were found for this question.', actions: ['rephrase', 'manual'] },
busy: { message: 'The assistant is busy right now. Your text is saved.', actions: ['retry', 'manual'] },
unavailable: { message: 'The assistant is unavailable right now. Your text is saved.', actions: ['retry', 'manual'] },
offline: { message: "You seem to be offline. We'll keep your text.", actions: ['retry'] },
timeout: { message: 'This is taking longer than usual.', actions: ['retry', 'manual'] },
bug: { message: "Something went wrong on our side. We've been notified.", actions: ['manual'] },
stopped: { message: 'Stopped. You can keep this partial draft.', actions: ['keep', 'retry'] },
};
/** Maps a completed response to an outcome. `hasContent` is your own check that the answer is useful. */
export function outcomeFromResponse(stopReason: Anthropic.Message['stop_reason'], hasContent: boolean): Outcome {
switch (stopReason) {
case 'end_turn':
case 'stop_sequence':
return hasContent ? 'ready' : 'empty';
case 'max_tokens':
case 'model_context_window_exceeded':
return 'truncated';
case 'refusal':
return 'declined';
case 'tool_use':
case 'pause_turn':
case null:
// This feature defines no tools: reaching here means a bug in the integration, not a user problem.
return 'bug';
}
}
/** Maps a thrown error to an outcome, most specific first. Log the original error before calling this. */
export function outcomeFromError(error: unknown, timedOut: boolean): Outcome {
if (timedOut) return 'timeout';
if (error instanceof Anthropic.RateLimitError) return 'busy';
if (error instanceof Anthropic.InternalServerError) return 'unavailable';
if (error instanceof Anthropic.APIConnectionError) return 'offline';
return 'bug';
} Your interface renders states[outcome].message and a button per action. The table also becomes documentation: product, design and support can read exactly what users see in every case, and review the copy without reading UI code.
Testing the unhappy paths
Unhappy paths are the ones least likely to be seen before launch, because the happy path is what everyone demos. Force each one deliberately:
- Truncation: set
max_tokensvery low. - Empty: ask a question your sources can’t answer.
- Declined: design and test the state with a mocked refusal response, rather than trying to provoke real refusals.
- Rate limits and server errors: mock the SDK in tests to throw
RateLimitErrorandInternalServerError. - Network failure: go offline in your browser’s developer tools mid-request.
- Timeout: set your own timeout very short.
Add a screenshot or test for each state to your pull request. It takes an hour and prevents the most embarrassing production moments.
Common mistakes
- Showing raw error codes. Log them; show plain language with a next step.
- Presenting truncated output as complete. Detect
max_tokensand label partial answers. - “Try again” for everything. Offer retry only when retrying can help; for refusals and invalid requests, offer rephrasing or the manual path.
- Lecturing on refusals. Keep refusal copy neutral and short, and offer an alternative.
- Never seeing the unhappy paths before launch. Force each state in development and in tests.
Try it
Exercise
About 60 min
Design and test every unhappy state of one feature
- Copy the state table into a feature and adapt the copy and actions to it.
- Wire your request code to
outcomeFromResponseandoutcomeFromError. - Force each state using the techniques above and take a screenshot of each.
- Ask someone who didn’t build the feature to read each screenshot and say what they would do next. Rewrite any copy that confuses them.
Show a hint
Start with the three most likely states in production: ready, truncated and busy. Then work through the rest.
Show a worked solution
A complete result has:
- A screenshot per outcome, each with plain-language copy and at least one action.
- No raw codes visible to users; the original errors are logged with request IDs.
- “Try again” only on busy, unavailable, offline, timeout and stopped.
- A manual path available from every unhappy state.
- At least one automated test per error type, using a mocked SDK.
Check your understanding
Key takeaways
- AI features fail in many distinct ways: truncated, declined, empty, rate limited, unavailable, offline, timed out, buggy, stopped.
- Good copy says what happened, why (if it helps), and what to do next, in plain language.
- Offer retry only when it can help, and always keep a manual path.
- Keep copy and actions in one typed table, and force every state before launch.
Progress is saved in this browser and lights up the roadmap.
Related articles
- What is an AI product engineer? An AI product engineer owns an AI feature end to end, from the user problem and UX to evals, launch metrics and cost. What the role covers and why it exists. Level: Beginner
- Streaming and loading states Design streaming responses and loading states for AI features, from when to stream to skeletons, progress, stop buttons and handling a stream that ends early. Level: Intermediate
- Guardrails for inputs, outputs and actions Add guardrails around AI features with input checks, output validation, action limits and refusal handling, and keep them from blocking real users. Level: Expert