Add Chat Error Messages

Su Suriya Ravichandran Updated 10 Nov 2026
10 min read ·Lesson 70 of 200

Why Error Messages Deserve Design

Things go wrong in every chat application: the user sends an empty message, the API is briefly overloaded, a rate limit is reached, the network drops, Claude declines a request, or a bug in your code raises an exception. How the application communicates these failures matters as much as preventing them.

Poor error handlingGood error handling
"Error 529" or a stack trace"The assistant is busy right now. Please try again in a moment."
The same message for every problemA message that tells the user what happened and what to do
A "Try again" button that can never succeedRetry offered only when retrying can help
Internal details shown to usersDetails logged for developers, never shown
The chat breaks after an errorThe user can continue the conversation

This lesson builds a central error catalog for the chatbot, connects it to the chat service and the web endpoints, and updates the browser page to show helpful messages with a retry option.


Categories of Chat Errors

CategoryExamplesWhose problem?Can retrying help?
Input problemsEmpty or too-long messageThe user's: they can fix itNo, until the input changes
Access problemsSession not found or not ownedUsually the user's (stale link)No
Claude's decisionsRefusal, truncated answerNeither: the request itselfSometimes, after rephrasing
Capacity problemsRate limit (429), overloaded or server errors (5xx)Temporary service stateYes, after waiting
Network problemsConnection failures, timeoutsTemporaryYes
Configuration problemsInvalid API key, permission denied, invalid request built by your codeThe developer'sNo, until fixed
Unexpected bugsExceptions in your own codeThe developer'sMaybe

Each category needs a different message, HTTP status, retry hint, and log level.


Principles for User-Facing Messages

  1. Say what happened, in plain language. "That message is too long" instead of "Validation error".
  2. Say what to do next. "Please shorten it and try again."
  3. Do not blame the user for problems that are not theirs.
  4. Never expose internal details: no exception names, stack traces, API keys, or raw API error text.
  5. Be honest about temporary problems. "Please try again shortly" sets the right expectation.
  6. Offer a retry only when it can help. Retrying a configuration error just repeats the failure.
  7. Log what users do not see, with request IDs (Lesson 49), so problems can be investigated.

Mapping Errors in One Place

The chatbot core (Lesson 61) returns short error codes in Reply.error: its own codes such as "empty_message" and "refusal", or the name of the SDK exception, such as "RateLimitError". A single catalog maps every code to a user message, an HTTP status, and a retry flag. The Flask and FastAPI endpoints and the streaming chat service all use the same catalog, so the same problem always produces the same message.

Reading Retry Hints

When the API returns a rate-limit error, the HTTP response can include a retry-after header that says how many seconds to wait. If you call the SDK directly, you can read it from the exception:

import anthropic

try:
    reply = client.messages.create(...)
except anthropic.RateLimitError as error:
    retry_after = error.response.headers.get("retry-after")   # e.g. "20", or None

Remember that the SDK already retries rate-limit and server errors automatically before raising them (Lesson 10). An error that reaches your code has already survived those retries, so asking the user to wait a little longer is reasonable. Lessons 141–146 build fuller retry strategies.


Practical Example: An Error Catalog for the Chatbot

errors.py

"""errors.py: map chatbot failure codes to user messages, HTTP statuses, and retry hints."""

from dataclasses import asdict, dataclass


@dataclass(frozen=True)
class ChatError:
    code: str          # Stable code for clients
    message: str       # Friendly text, safe to show users
    status: int        # HTTP status when returned before a stream starts
    retryable: bool    # Whether trying again can help


BUSY = ChatError("service_busy", "The assistant is busy right now. Please wait a moment and try again.", 429, True)
UNAVAILABLE = ChatError("service_unavailable", "The assistant is temporarily unavailable. Please try again shortly.", 503, True)
CONFIG = ChatError("service_unavailable", "The assistant is not available right now. Please try again later.", 503, False)
DEFAULT = ChatError("internal_error", "Something went wrong. Please try again.", 500, True)

CATALOG: dict[str, ChatError] = {
    # Input problems: the user can fix these
    "empty_message": ChatError("empty_message", "Please type a message.", 400, False),
    "message_too_long": ChatError("message_too_long", "That message is too long. Please shorten it and try again.", 400, False),
    # Access problems
    "session_not_found": ChatError("session_not_found", "That conversation was not found. Start a new chat to continue.", 404, False),
    # Claude's decisions (only reported inside streams, where the status is already 200)
    "refusal": ChatError("refusal", "I can't help with that request. You can try asking in a different way.", 200, False),
    # Capacity and network problems: temporary
    "RateLimitError": BUSY,
    "InternalServerError": UNAVAILABLE,
    "APIStatusError": UNAVAILABLE,
    "APIConnectionError": UNAVAILABLE,
    "APITimeoutError": ChatError("timeout", "The answer took too long. Please try again.", 504, True),
    # Configuration problems: the developer must fix these
    "AuthenticationError": CONFIG,
    "PermissionDeniedError": CONFIG,
    "NotFoundError": CONFIG,
    "BadRequestError": ChatError("request_failed", "This request could not be processed. Please start a new chat.", 500, False),
}

# Codes that are expected outcomes, not incidents worth a warning in the logs
EXPECTED = {"empty_message", "message_too_long", "session_not_found", "refusal"}


def describe(code: str | None) -> ChatError:
    return CATALOG.get(code or "", DEFAULT)


def as_event(error: ChatError) -> dict:
    data = asdict(error)
    data.pop("status")
    return {"type": "error", **data}

Updated chat_events() in chat_service.py

This replaces the Lesson 66 version. Errors now use the catalog, failures are logged, and unexpected exceptions become a friendly event instead of a broken stream.

import logging

from errors import EXPECTED, as_event, describe

logger = logging.getLogger("chat")


def chat_events(user_id: str, session_id: str, text: str) -> Iterator[str]:
    """Stream one chat turn as server-sent events, then save it."""
    try:
        with Services() as s:
            bot = s.sessions.open(session_id, user_id)
            section = s.memory.system_section(user_id)
        bot.config = replace(BASE_CONFIG, system=f"{BASE_SYSTEM}\n\n{section}" if section else BASE_SYSTEM)
        is_first_turn = not bot.history

        reply = None
        for event in bot.stream_events(text):
            if event["type"] == "text":
                yield sse({"type": "text", "text": event["text"]})
            else:
                reply = event["reply"]

        if reply is None or not reply.ok:
            code = reply.error if reply else None
            if code not in EXPECTED:
                logger.warning("chat turn failed: code=%s session=%s", code, session_id)
            yield sse(as_event(describe(code)))
            return

        with Services() as s:
            s.sessions.save_turn(session_id, user_id, reply)
            if is_first_turn:
                s.sessions.auto_title(session_id, user_id, text)
    except Exception:
        logger.exception("unexpected error in chat turn: session=%s", session_id)
        yield sse(as_event(describe(None)))
        return

    yield sse({"type": "done", "truncated": reply.stop_reason == "max_tokens"})

    try:
        with Services() as s:
            known = [fact for _, fact in s.memory.facts(user_id)]
            s.memory.add(user_id, extract_facts(client, text, known))
    except Exception:
        logger.exception("memory update failed: session=%s", session_id)

Using the Catalog in the Endpoints

Pre-stream errors in Flask (Lesson 67) and FastAPI (Lessons 68–69) use the same catalog, so messages are identical everywhere:

from errors import describe


def error_for(code: str):
    problem = describe(code)
    return error(problem.code, problem.message, problem.status)

# For example, in post_chat():
#     if not text.strip():
#         return error_for("empty_message")
#     ...
#     except SessionNotFound:
#         return error_for("session_not_found")

Updated Browser Handling

In static/chat.html (Lesson 66), replace the error branch of the event loop, so retryable errors offer a Try again button:

else if (event.type === "error") {
  bubble.textContent += (bubble.textContent ? "\n" : "") + "[" + event.message + "]";
  if (event.retryable) {
    const retry = document.createElement("button");
    retry.textContent = "Try again";
    retry.onclick = () => { bubble.remove(); retry.remove(); sendMessage(text, {retrying: true}); };
    bubble.after(retry);
  }
}

And change the start of sendMessage() so a retry does not add the user's message a second time:

async function sendMessage(text, {retrying = false} = {}) {
  if (!retrying) addMessage("user", text);
  const bubble = addMessage("assistant", "");
  // ... the rest of the function is unchanged
}

Example of what users see:

You:    Explain Python closures with an example.
Claude: [The assistant is busy right now. Please wait a moment and try again.]  [Try again]

You:    (empty message)
        [Please type a message.]

You:    Can you help me with something harmful...
Claude: [I can't help with that request. You can try asking in a different way.]

How It Works

  1. ChatError describes each failure with four pieces of information: a stable code for programs, a friendly message for people, an HTTP status for errors detected before streaming, and a retryable flag.
  2. CATALOG maps the core's codes and SDK exception names to ChatError values. Several codes share one entry, such as BUSY or UNAVAILABLE, so similar problems produce the same message. Configuration errors such as an invalid API key show a neutral message and are not retryable, because they need a developer, not another attempt.
  3. describe() falls back to DEFAULT for unknown codes, so a new or unexpected error still produces a sensible message.
  4. as_event() turns a ChatError into a stream event without the HTTP status, which is meaningless once a stream has started.
  5. chat_events() now:
    • logs failures that are not expected outcomes (EXPECTED), so the logs show real incidents, not every empty message;
    • catches unexpected exceptions, such as a database error, logs them with logger.exception() (which includes the stack trace), and sends a friendly error event, so the browser never receives a half-finished stream with no explanation;
    • protects the best-effort memory step with its own try, so a memory failure is logged but never affects the user.
  6. The browser shows each error's message, and adds a Try again button only when retryable is true. A retry removes the failed bubble and resends the same text without adding the user's message again. Because the server never saves failed turns (Lesson 61), retrying cannot create duplicate history.

Common Mistakes

Showing Raw Exception Text

What happens: users see messages like Error code: 529 - {'type': 'error', ...}, which are confusing and reveal internals.

Fix: map every error to a friendly catalog message; log the details.

Offering Retries That Cannot Succeed

What happens: users click "Try again" repeatedly on an invalid-key or bad-request error.

Fix: mark only temporary problems as retryable.

Different Messages for the Same Problem

What happens: the CLI, the web page, and the API describe the same failure differently.

Fix: use one catalog everywhere.

Silent Failures in Streams

What happens: an exception ends the stream without an event, and the browser shows a partial or empty answer.

Fix: catch exceptions in the stream generator and always send a final done or error event.

Logging Everything as an Error

What happens: expected events such as empty messages flood the logs and hide real incidents.

Fix: separate expected outcomes from incidents, and log each at an appropriate level.


Best Practices

  • Keep one error catalog shared by every interface.
  • Write messages that explain what happened and what to do, in plain language.
  • Use stable error codes for programs and friendly messages for people.
  • Offer retries only for temporary problems.
  • Always end a stream with done or error.
  • Log incidents with context (session, request ID), and never show internal details to users.
  • Never let optional steps, such as titles and memory, turn a successful answer into an error.

Exercise

  1. Add a "rate_limited" entry for the per-user limit from Lesson 67's exercise, with a message telling the user how long to wait.
  2. Translate the catalog's messages into Tamil or another language, and choose the language from a request header such as Accept-Language.
  3. Write a unit test that checks every catalog entry has a non-empty message and that configuration errors are not retryable.
  4. Disconnect your network while the web chatbot is answering. Which message appears, and is a retry offered?
Show answers for steps 3 and 4

Step 3:

from errors import CATALOG, CONFIG, describe


def test_catalog_messages_are_present():
    for code, problem in CATALOG.items():
        assert problem.message.strip(), f"{code} has no message"


def test_configuration_errors_are_not_retryable():
    for code in ["AuthenticationError", "PermissionDeniedError", "NotFoundError"]:
        assert describe(code) is CONFIG
        assert describe(code).retryable is False


def test_unknown_codes_get_the_default():
    assert describe("SomethingNew").code == "internal_error"

Run with pytest. Tests like these protect the catalog when new codes are added.

Step 4: if the server loses its connection to the Claude API, the core returns "APIConnectionError", which maps to service_unavailable with "The assistant is temporarily unavailable. Please try again shortly." and a Try again button. If instead the browser loses its connection to your server, the stream ends without an event. In that case, the page should treat the end of the stream without a done or error event as an interruption and offer a retry itself.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Add Chat Error Messages and get answers drawn from it.

Signed-in readers only.