Add Chat Error Messages
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 handling | Good 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 problem | A message that tells the user what happened and what to do |
| A "Try again" button that can never succeed | Retry offered only when retrying can help |
| Internal details shown to users | Details logged for developers, never shown |
| The chat breaks after an error | The 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
| Category | Examples | Whose problem? | Can retrying help? |
|---|---|---|---|
| Input problems | Empty or too-long message | The user's: they can fix it | No, until the input changes |
| Access problems | Session not found or not owned | Usually the user's (stale link) | No |
| Claude's decisions | Refusal, truncated answer | Neither: the request itself | Sometimes, after rephrasing |
| Capacity problems | Rate limit (429), overloaded or server errors (5xx) | Temporary service state | Yes, after waiting |
| Network problems | Connection failures, timeouts | Temporary | Yes |
| Configuration problems | Invalid API key, permission denied, invalid request built by your code | The developer's | No, until fixed |
| Unexpected bugs | Exceptions in your own code | The developer's | Maybe |
Each category needs a different message, HTTP status, retry hint, and log level.
Principles for User-Facing Messages
- Say what happened, in plain language. "That message is too long" instead of "Validation error".
- Say what to do next. "Please shorten it and try again."
- Do not blame the user for problems that are not theirs.
- Never expose internal details: no exception names, stack traces, API keys, or raw API error text.
- Be honest about temporary problems. "Please try again shortly" sets the right expectation.
- Offer a retry only when it can help. Retrying a configuration error just repeats the failure.
- 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
ChatErrordescribes each failure with four pieces of information: a stablecodefor programs, a friendlymessagefor people, an HTTPstatusfor errors detected before streaming, and aretryableflag.CATALOGmaps the core's codes and SDK exception names toChatErrorvalues. Several codes share one entry, such asBUSYorUNAVAILABLE, 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.describe()falls back toDEFAULTfor unknown codes, so a new or unexpected error still produces a sensible message.as_event()turns aChatErrorinto a stream event without the HTTP status, which is meaningless once a stream has started.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.
- logs failures that are not expected outcomes (
- The browser shows each error's message, and adds a Try again button only when
retryableis 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
doneorerror. - 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
- 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. - Translate the catalog's messages into Tamil or another language, and choose the language from a request header such as
Accept-Language. - Write a unit test that checks every catalog entry has a non-empty message and that configuration errors are not retryable.
- Disconnect your network while the web chatbot is answering. Which message appears, and is a retry offered?
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.