Lesson 22: Exception Handling
"One pattern, repeated eight times, with two quiet exceptions to the rule."
The Pattern, in Full
Every write endpoint that calls an LLM follows this shape:
try:
result = some_llm_call(...)
log_tool_call(db=db, ..., success=True, ...)
db.commit()
return result
except Exception as e:
log_tool_call(db=db, ..., success=False, error_message=str(e))
db.commit()
raise HTTPException(status_code=500, detail=str(e))This exact shape appears in: analyze_agent_email, run_multi_agent_email_workflow, search_documents, and ask_agent_documents — four endpoints, each independently implementing the same try/log/re-raise structure. There is no shared error-handling decorator or FastAPI exception handler doing this centrally; it's copy-pasted logic.
except Exception — Deliberately Broad
Every one of these catches the base Exception class, not a specific exception type. This means a ValueError from analyze_email (malformed JSON from the LLM), an openai.APIError (network failure), and a sqlalchemy.exc.IntegrityError (a database constraint violation) are all handled identically: logged as a failed ToolCall, then re-raised as a generic 500 with str(e) as the detail. This is simple and consistent, but it also means the client never learns which of those three very different problems occurred — a transient network blip and a permanent "the LLM never produces valid JSON for this input" bug both look like the same 500 Internal Server Error from outside.
chat_with_agent Is the One Endpoint That Doesn't Re-Raise
try:
assistant_text = generate_agent_response(...)
status = "success"
error_message = None
except Exception as e:
assistant_text = "Sorry, I could not generate a response right now."
status = "failed"
error_message = str(e)
# ... execution continues normally, messages are saved either way, HTTP 200 either way ...This is a graceful degradation pattern, not the fail-loud pattern used everywhere else. A chat failure never becomes an HTTP error — the client always gets a 200 with a ChatResponse, and the failure is only visible by inspecting AgentRun.status or the fallback text itself. This is a deliberate, reasonable choice for a chat UI (you don't want a broken conversation turn to look like a network error to the end user) — but it means chat_with_agent's error handling is architecturally different from every other LLM-calling endpoint in the file, worth noticing precisely because it's inconsistent with the majority pattern.
str(e) Reaching the Client Is a Real Information-Disclosure Risk
raise HTTPException(status_code=500, detail=str(e)) puts the raw Python exception message directly into the HTTP response body. For a ValueError("OPENAI_API_KEY is missing...") this is harmless and even helpful. For a database-level exception, str(e) can include table names, column names, or fragments of the failing SQL statement — internal implementation details a public API generally shouldn't expose to callers. See Lesson 25: Security for the broader context.
document_service.py and rag_graph_service.py Have No Exception Handling of Their Own
Neither create_embedding(), add_chunk_to_vector_store(), search_agent_documents(), nor any node function in the LangGraph pipeline catches anything — they let exceptions propagate straight up to whichever router called them. All exception handling in the RAG path lives entirely in the two router endpoints (search_documents, ask_agent_documents), not in the service layer. This is a consistent choice across the codebase: services raise, routers catch — the one partial exception being the upload endpoint's chunk loop, which has no try/except at all (Lesson 09b), so a mid-loop failure there propagates all the way to FastAPI's default unhandled-exception behavior (an unstructured 500 with no ToolCall logged).
What FastAPI Would Offer That This Codebase Doesn't Use
FastAPI supports global exception handlers via @app.exception_handler(SomeExceptionType), which would let you centralize the "catch, log, convert to HTTPException" pattern instead of repeating it in four endpoints. This codebase has none registered — main.py has no exception_handler decorators at all.
Common Mistakes
- Assuming every LLM-calling endpoint returns an error status on failure.
chat_with_agentis the one exception — it always returns200. - Assuming
str(e)in a500response is safe to display to end users verbatim. It can leak internal details. - Assuming the document upload loop has the same safety net as
search/ask. It doesn't — see Lesson 09b.
Exercises
- Replace
except Exception as e: raise HTTPException(500, str(e))insearch_documentswith two specificexceptblocks — one forValueError(bad input/config) mapped to400, one for everything else mapped to500with a generic message instead ofstr(e). - Write a FastAPI global exception handler (
@app.exception_handler(Exception)) that logs unhandled exceptions and returns a generic{"detail": "internal error"}body, and explain what class of bug it would catch that the current per-endpointtry/exceptblocks miss (hint: the upload loop). - Make
chat_with_agent's error handling consistent with the rest of the file by adding an optionalstrict: boolquery parameter that, when true, re-raises as a500instead of falling back to a friendly message. What does this trade off?
Key Takeaways
🎯 Four endpoints repeat the identical try/log/re-raise pattern with no shared abstraction.
🎯 except Exception is used everywhere — broad by design, at the cost of the client never learning which kind of failure occurred.
🎯 chat_with_agent is architecturally different: it degrades gracefully (200 + fallback text) instead of raising.
🎯 str(e) reaching HTTP responses is a real, if currently low-severity, information-disclosure surface.
🎯 The document upload loop is the one write path with no exception handling at all.
Next Steps
Module 6 · Production Mastery