AI prototypes that skip error states

The prototype has one screen: enter a question, watch three dots, receive a polished answer. There is no timeout, no empty result, no refusal, no rate limit, no malformed tool call, and no way to tell whether a partially streamed answer is complete.

That prototype is not a neutral simplification. It has quietly promised that the system always responds, always has enough context, never gets blocked, and never leaves work half done. Engineers then inherit those assumptions as product requirements, even though the real system cannot keep them.

AI prototypes that skip error states are especially expensive because the failure states are not cosmetic. They determine whether a user retries a charged request, trusts an incomplete answer, waits through an outage, or accidentally repeats an external action. Designing those states early is a way to define the actual product contract before the implementation hardens around a happy-path demo.

A prototype is an agreement about what happens next

A mockup does more than show visual hierarchy. It tells users what the system will do, what they can do in response, and whether an action has taken effect. A button labeled “Generate” followed by a message that says “Done” creates a stronger promise than “Draft ready for review.”

The hidden promise in a happy-path-only AI prototype is usually one of these:

  • the model will answer quickly;
  • the model will have the right data;
  • the output will be complete and valid;
  • the user’s permissions will not block the action;
  • the provider will be available;
  • the model will not refuse;
  • retries are harmless;
  • any write either completes fully or does nothing.

None of those promises should be implicit. Make them decisions in the prototype. If the system cannot guarantee an outcome, show what it does instead.

The first design artifact should be a state map, not a sequence of polished screens. For each user action, list the expected state transitions and the failure conditions that change them. A small feature may have more meaningful states than screens.

Map failures by layer

“The AI failed” is too broad to design against. Failures originate in different layers, and the right response depends on the cause.

Layer Example Likely user response
Input validation File too large or unsupported Correct input or choose another file
Authentication Session expired Re-authenticate, preserve the draft
Authorization User cannot access the record Explain access boundary or request permission
Retrieval No relevant source found Ask for context or show search results
Model policy Request refused Explain the limit and offer an allowed alternative
Provider capacity Rate limit or overload Wait, queue, or try a safe fallback
Output contract Invalid JSON or missing field Retry safely or route to review
Tool execution Tool rejected or partially completed Reconcile state; do not claim success
Client connection Stream disconnected Show partial status and recovery options
Product policy Approval required Save a proposal and route for approval

The same HTTP status can imply different product choices. A 429 caused by request rate may be transient; a 429 caused by an exhausted spend limit will not resolve if the client retries. OpenAI’s current error guide distinguishes rate limits, spend limits, usage limits, 500 errors, and model overload, and advises honoring Retry-After where provided. Those distinctions belong in the backend error model and should map to different user states. (OpenAI, error codes)

1. Give the screen a state model

Represent the request lifecycle explicitly. For a chat-based feature, a useful starting point is:

idle
  → validating
  → waiting
  → streaming
  → complete
  → needs_input
  → queued_for_review
  → retryable_error
  → terminal_error
  → cancelled
  → outcome_unknown

Not every product needs every state. The important thing is to distinguish states that require different behavior. retryable_error can offer a retry; terminal_error needs correction or support; outcome_unknown must reconcile before repeating a write; needs_input is not an error at all.

Do not compress all failures into one error screen with a “Try again” button. That button may be harmful when the previous request could have executed an external action. A retry on a text-only summary is different from a retry on “send this email” or “issue this refund.”

The prototype should show the transition, not just the destination. If the user can cancel while the model is streaming, define what cancellation means. If the tool call has already been sent, the system may be unable to cancel the external action. The UI should not promise “Cancelled” until the operation state confirms it.

2. Design “not enough context” as a normal outcome

Many AI products prototype a confident answer for every question. Real retrieval systems often return no relevant documents, conflicting sources, or a stale record. The product must decide whether the model should answer, ask a question, or expose the evidence.

A useful empty state says what is missing and offers an action:

I couldn’t find an order matching that number. Check the digits or search by email.

That is better than a generic “I don’t know” and much better than a fabricated answer. For retrieval-based features, consider showing the source set or a search-results alternative. A zero-result answer is sometimes the right answer, but the user should be able to tell whether the system searched and found nothing or never received the expected context.

Conflicting data deserves a separate state. If the CRM says one renewal date and the billing system says another, the assistant should not silently pick one. Explain the conflict, identify the authoritative owner if known, and route the case to the appropriate workflow.

Prototype these states with realistic missing and conflicting data. If design only sees perfect fixtures, the resulting interface will teach engineers to treat empty retrieval as an exceptional edge case instead of a core product outcome.

3. Preserve user work through failures

Losing the user’s message, uploaded file, or edited draft after a timeout makes the system feel unreliable even when the provider is at fault. Preserve local input while the request is in flight and restore it when a request fails. If the file itself cannot be retained for privacy reasons, retain a clear file reference and explain when the user must upload it again.

Keep the user’s draft separate from the model’s response. If generation fails, the user should not have to reconstruct their prompt. If a streamed answer stops halfway, mark it as incomplete and do not append a later retry as if the two fragments were one complete answer.

For side-effecting workflows, preserve the operation ID and request status. “Retry” should resume the same logical operation when safe, not create a second one. If the system cannot determine whether the first action succeeded, show “Checking status” or “Needs support,” not a button that blindly repeats it.

4. Make waiting honest

An animated spinner implies that progress is happening. If the system has no estimate and no progress signal, a spinner that runs for 90 seconds is not feedback; it is uncertainty with motion.

Choose an interaction pattern based on the work:

  • Short, interactive call: show a clear waiting state and a reasonable timeout.
  • Streaming generation: render partial text but label the response as still generating.
  • Long-running task: return a task ID, allow navigation away, and provide completion notification.
  • Queue or human review: say that the work is queued and provide a status view.
  • Timeout: preserve input and explain whether retry is safe.

Do not invent a percentage complete unless the backend can support it. “Analyzing your 42-page document” can be useful if the system knows that stage is occurring; a progress bar that advances on a timer is misleading.

If a response streams and then disconnects, show the partial content as partial. Users need to know whether the conclusion, caveat, or final structured object was emitted. A truncated response is not a successful answer merely because the first paragraph looks polished.

5. Separate refusal, policy block, and technical failure

These cases need different copy and recovery:

  • Refusal: the model or policy will not assist with the requested content. Offer a safe adjacent action when appropriate.
  • Authorization block: the user cannot access the data or perform the action. Explain who can grant access or how to request it.
  • Validation failure: the request is missing a required field or has an invalid format. Show the field and correction.
  • Provider failure: the service is temporarily unavailable. Explain whether the request is queued, retryable, or failed.
  • Application failure: the system could not complete a step. Preserve input and surface a reference ID for support.

Do not expose raw provider error messages to users. They may contain implementation details, identifiers, or confusing jargon. Map errors to a small product taxonomy, log the provider code internally, and show actionable copy.

Also avoid treating every refusal as a model-quality issue. Some refusals are correct; some are overly broad; some arise because the prompt did not provide enough context. The prototype should show how a user can clarify or appeal when the product supports it.

6. Design partial success and unknown outcomes

An AI workflow may complete some steps and fail others. A meeting assistant might create a calendar event but fail to send the invitation. A CRM agent might update a note but fail to change the account owner. A prototype that shows a single “Success” state cannot represent these outcomes.

Model each side effect separately:

draft_created: succeeded
record_updated: succeeded
notification_sent: unknown

The interface can then say what completed and what remains. If the notification’s outcome is unknown because the connection dropped after sending, the system should check delivery or route to reconciliation before retrying.

For actions that cannot be cleanly undone, explain the compensation path. “Undo” may mean creating a reversal transaction rather than erasing history. Do not design an undo button until the backend has a real, tested operation behind it.

7. Prototype the retry contract

A retry button is a product promise. It says the system can try again without causing an unintended duplicate. Prototype its behavior explicitly:

  • Does it reuse the same operation ID?
  • Does it check whether an earlier attempt completed?
  • Does it respect the server’s retry delay?
  • Does it preserve the prompt and attached context?
  • Does it make clear whether the new attempt may cost more?
  • What happens after the retry budget is exhausted?

Transient rate limits and temporary overload can be retryable. Invalid requests, authentication failures, permission denials, and exhausted spend limits require a different response. The UI should not tell the user to keep retrying a condition that will not change by waiting.

For a read-only answer, an automatic bounded retry may be invisible. For an irreversible write, the user may need to see a status check or approval step. The design can differ by action type even when both operations use the same model provider.

8. Include accessibility in error-state design

Error states are often more difficult to use than the happy path. Screen readers need meaningful live announcements when streaming ends or fails. Focus should move to a validation message when appropriate. Error meaning must not rely on color alone. Retry and cancel controls need keyboard access and clear labels.

Preserve the text of a user’s request when a screen reader announces an error. Do not put a long technical stack trace into an accessible description. Provide a concise explanation and a support reference ID, with detailed diagnostics available to authorized staff.

When the product supports multiple languages, localize error states and safe alternatives along with the success state. A prototype in one language often misses text expansion, right-to-left layout, and how a refusal or policy explanation reads in another locale.

9. Turn the state map into tests

An error-state design is most useful when it becomes executable acceptance criteria. For each state, define:

  • the event that enters it;
  • the data the user sees;
  • actions the user can take;
  • whether retry is safe;
  • how the state is logged;
  • the transition that exits it;
  • the expected behavior if the state persists.

Example:

State: outcome_unknown
Entry: client timed out after the side-effect request was submitted
Display: “Checking whether the change completed” plus operation reference
Allowed actions: refresh status, contact support
Forbidden action: submit a new operation with a new idempotency key
Exit: reconciled as succeeded, failed, or escalated

This can drive tests for the API, the UI, and the workflow engine. It also gives support a vocabulary that matches the product. A screen that says “Try again” while the backend state is unknown is now an acceptance-test failure, not a matter of taste.

Inject provider timeouts, 429s, 503s, empty retrieval, malformed tool outputs, and permission denials in staging. The current OpenAI error guide, for example, distinguishes rate limits from exhausted spend limits and model overload; your application should map those codes to different recovery paths rather than one generic failure. (OpenAI, error codes)

10. Prototype uncertainty without hiding it

An AI prototype often shows a polished answer because the designer wants to test the concept. That is reasonable if the prototype explicitly labels the answer as sample content. It becomes misleading when stakeholders infer that retrieval, permissions, and latency have already been solved.

Mark simulated behavior. Keep a list of assumptions beside the prototype: data is available, permission checks succeed, tool calls are deterministic, or errors are not represented. The product review can then decide which assumptions are valid and which require a design state.

For interactive prototypes, create at least one path where the model asks a clarifying question, one where it returns no answer, one where it refuses, and one where a tool call fails. If those paths make the concept unusable, that is valuable discovery before implementation—not a reason to omit them.

Define a stable error contract between backend and UI

The UI should not branch on raw provider messages. Build a backend error taxonomy with a stable code, a retry policy, and a user action. The provider adapter maps SDK exceptions and HTTP codes into that taxonomy; the frontend renders the state without needing to know which model provider was used.

from enum import StrEnum
from dataclasses import dataclass

class FailureKind(StrEnum):
    RETRYABLE = "retryable"
    NEEDS_INPUT = "needs_input"
    NOT_AUTHORIZED = "not_authorized"
    POLICY_BLOCK = "policy_block"
    QUOTA_EXHAUSTED = "quota_exhausted"
    OUTCOME_UNKNOWN = "outcome_unknown"

@dataclass(frozen=True)
class AppFailure:
    kind: FailureKind
    code: str
    operation_id: str
    retry_after_seconds: int | None = None

def map_provider_error(status: int, code: str | None, operation_id: str) -> AppFailure:
    if status == 429 and code in {"rate_limit_exceeded", "slow_down"}:
        return AppFailure(FailureKind.RETRYABLE, "provider_rate_limited", operation_id)
    if status == 429 and code in {"credit_balance_exhausted", "project_spend_limit_exceeded"}:
        return AppFailure(FailureKind.QUOTA_EXHAUSTED, "account_limit", operation_id)
    if status in {401, 403}:
        return AppFailure(FailureKind.NOT_AUTHORIZED, "service_configuration", operation_id)
    if status in {500, 503}:
        return AppFailure(FailureKind.RETRYABLE, "provider_unavailable", operation_id)
    return AppFailure(FailureKind.OUTCOME_UNKNOWN, "unclassified_failure", operation_id)

The mapping is illustrative and must match the provider’s current error contract. In particular, a 429 can mean transient rate limiting or a billing/usage limit; those deserve different messages and retry behavior. An unknown error should not default to “retry” when the request may have performed a side effect.

Expose a stable code and operation reference to the UI. Keep raw provider error text in restricted logs, with secrets removed. This makes it possible to change providers without redesigning the user experience for every SDK exception.

Test the error experience with realistic timing

Error screens are often reviewed as static images, but timing changes what users perceive. A 200 ms validation error feels immediate. A failure after 30 seconds of generation feels like lost work. A disconnect after several streamed paragraphs raises a different question: should the user keep the partial text, retry, or ask for completion?

Prototype at least three timing profiles:

  • failure before the first token;
  • failure after partial output;
  • completion after the user navigates away or cancels.

Ask reviewers to perform tasks, not just comment on colors. Can they tell whether the action happened? Can they recover the text they wrote? Can they distinguish a temporary outage from a policy refusal? Do they know whether retrying could submit a second payment or email?

For a long-running task, test the status view on a second device or after a page refresh. The user should not have to keep a browser tab open merely because the prototype assumed a single synchronous request. If the task moves to a queue, show where it went and how the user will know it finished.

Be honest about what the product can promise

Error-state copy can accidentally promise reliability the backend does not provide. “We’ll keep trying” implies automatic retry. “Your request is saved” implies durable persistence. “We cancelled the task” implies the worker and any downstream action stopped. Use those statements only when the system can prove them.

Use language tied to verified state:

  • “The draft is saved” only after the save is committed.
  • “The update is being checked” when the remote outcome is unknown.
  • “No changes were made” only when the application knows no write occurred.
  • “You can retry” only when retry is safe or the same operation ID will be reused.

If the product cannot guarantee that a request remains available after a browser refresh, say so and give a copy/export option. If the workflow is asynchronous, show the task ID or status location. Honest limitations are better than reassuring copy that turns into a support incident.

Designers and engineers should agree on these phrases alongside the API state machine. Otherwise the UI may describe a more optimistic world than the service actually implements.

Keep the fallback proportional to the failure

Not every error should collapse the entire feature. If the model cannot generate a polished summary but the application can still show the retrieved source documents, present those documents. If an optional tool is down, explain which part is unavailable and preserve other completed work. If authorization fails, do not offer a retry that simply repeats the same forbidden request.

Define which failures are recoverable by the user, which recover automatically, and which need an operator. Automatic retries should be bounded and usually invisible only for safe read-only calls. For a write, show a pending state or use an idempotent operation record so that a retry resumes the same action. If a human must intervene, show the queue or expected next step rather than leaving the user in an indefinite spinner.

The fallback also needs a named owner. A static “try later” message can be acceptable for a short provider outage, but a stuck operation with a possible external side effect needs reconciliation. The product’s error taxonomy should route those states to the team responsible for recovery, and the alert should include a safe operation reference that support can find without collecting the full prompt from the user.

Preserve independent parts of the interface. A failed AI panel should not block a form, dashboard, or manual workflow that does not depend on the model. This is one of the most important things an early prototype can reveal: whether the AI feature is an enhancement to the product or has accidentally become a single point of failure. Keep those non-AI routes available during degradation, and exercise them during launch drills.

Make sure the fallback is visible and documented for support teams. Review it after incidents. Revisit when assumptions change.

A launch-oriented error-state checklist

Before handing a prototype to engineering, answer:

  1. What happens when the model is slow, unavailable, rate-limited, or out of quota?
  2. What does the user see when retrieval is empty, stale, or conflicting?
  3. How are refusal, authorization failure, validation error, and provider failure distinguished?
  4. Is partial output marked incomplete?
  5. Can the user cancel, and what does cancellation guarantee?
  6. Can a retry duplicate a tool action or charge?
  7. What is shown when the outcome is unknown?
  8. Does the user’s input survive a retry or failure?
  9. Is partial workflow success visible and recoverable?
  10. Do screen readers and keyboard users receive the same state information?
  11. Is the model’s uncertainty conveyed without presenting guesses as facts?
  12. Which failure states are blockers, and which are acceptable with a fallback?

If the prototype cannot answer these questions, it is not ready to define the production behavior. It can still be a useful concept sketch, but it should be labeled as one. The cost of designing error states early is a few more screens and a clearer contract. The cost of skipping them is a product whose first real interaction with failure is an incident.

Post a Comment

Previous Post Next Post