Skip to content
Module 6 · Production MasteryAdvanced5 min read · 914 words

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:

python
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

python
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_agent is the one exception — it always returns 200.
  • Assuming str(e) in a 500 response 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

  1. Replace except Exception as e: raise HTTPException(500, str(e)) in search_documents with two specific except blocks — one for ValueError (bad input/config) mapped to 400, one for everything else mapped to 500 with a generic message instead of str(e).
  2. 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-endpoint try/except blocks miss (hint: the upload loop).
  3. Make chat_with_agent's error handling consistent with the rest of the file by adding an optional strict: bool query parameter that, when true, re-raises as a 500 instead 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

👉 Lesson 23: Testing →


Module 6 · Production Mastery