Skip to main content
C carlos.enredando.me CTO · Advisor · Builder

Mastering Agentic AI: Exception Handling and Recovery

·1333 words·7 mins
Carlos Prados
Author
Carlos Prados
Telecommunications Engineer, Entrepreneur, CTO & CIO, Team Leader & Manager, IoT-M2M-Big Data Consultant, Pre-sales Engineer, Product-Service Manager & Strategist.

In my previous post, we looked at Goal Setting and Monitoring — giving an agent an explicit objective and a way to check whether it’s actually getting there. That’s the optimistic half of autonomy: the agent knows what “done” looks like and steers toward it.

This post is about the other half. The pessimistic half. The one that decides whether your agent survives contact with the real world.

Because here’s the uncomfortable truth: every external call your agent makes will eventually fail. The API times out. The tool returns garbage. The precise lookup service doesn’t know about the city the user asked for. In a demo, you never see it. In production, it’s Tuesday. The Exception Handling and Recovery pattern is what separates an agent that degrades gracefully from one that throws a stack trace at your user.

Pattern #12: Exception Handling and Recovery
#

The Problem
#

A naive agent treats every step as if it will succeed. It calls a tool, assumes a clean result, and feeds that result into the next step. The whole pipeline is a chain of optimistic assumptions, and a single failed link snaps the entire thing.

Imagine a location assistant backed by a precise geolocation service — one that returns exact coordinates and curated descriptions, but only for a curated set of cities. A user asks about Paris: perfect, instant, precise answer. A user asks about Reykjavik: the service has never heard of it. Now what?

The wrong answers are everywhere in real systems:

  • Crash: the exception propagates and the user gets nothing.
  • Lie: the agent hallucinates a “precise” answer it doesn’t actually have.
  • Dead end: the agent returns “I can’t help with that” and gives up on a query it could partially answer.

What you actually want is graceful degradation: when the precise path fails, fall back to a less precise but still useful one — and be honest about which one you used. That’s not error handling bolted on as an afterthought. In an agentic system, the recovery path is a first-class part of the control flow.

The Solution
#

The cleanest way to model recovery is to make failure an explicit branch in the graph. Instead of wrapping everything in a giant try/except, you give the workflow a primary handler, a fallback handler, and a router that decides between them based on whether the primary succeeded.

Here’s the state. Note that primary_failed is part of the state itself — failure is data the graph reasons about, not an exception it catches:

class FallbackState(TypedDict):
    query: str
    primary_failed: bool
    location_result: str
    response: str

The primary handler attempts the precise operation. If it can’t satisfy the request, it doesn’t raise — it records the failure in the state and returns:

def primary_handler(state: FallbackState) -> dict:
    """Attempts precise location lookup (simulates failure for certain queries)."""
    known_locations = {
        "paris": "Paris, France: 48.8566N, 2.3522E - The City of Light",
        "tokyo": "Tokyo, Japan: 35.6762N, 139.6503E - The world's largest metropolis",
    }

    query_lower = state["query"].lower()
    for key, info in known_locations.items():
        if key in query_lower:
            return {"primary_failed": False, "location_result": info}

    # No precise data — signal failure instead of guessing or crashing.
    return {"primary_failed": True, "location_result": ""}

The fallback handler kicks in only when the primary failed. Here it leans on the LLM’s general knowledge — less precise, but better than nothing — and the system prompt is explicit that the precise service was unavailable:

def fallback_handler(state: FallbackState) -> dict:
    """Provides a general response when the primary handler fails."""
    llm = get_llm(temperature=0)
    prompt = ChatPromptTemplate.from_messages([
        ("system",
         "The precise location service is unavailable for this query. "
         "Provide general information about the location from your knowledge."),
        ("user", "{query}")
    ])
    chain = prompt | llm | StrOutputParser()
    result = chain.invoke({"query": state["query"]})
    return {"location_result": result}

The router is the heart of the pattern. It reads the failure flag and sends the workflow down the recovery branch or straight to formatting:

def route_after_primary(state: FallbackState) -> Literal["fallback_handler", "response_formatter"]:
    if state["primary_failed"]:
        return "fallback_handler"
    return "response_formatter"

A final response formatter runs no matter which path was taken, and — crucially — it tells the user which source answered them. Transparency about degradation is part of the contract:

def response_formatter(state: FallbackState) -> dict:
    """Formats the final response regardless of which handler produced it."""
    source = "fallback service" if state["primary_failed"] else "primary service"
    response = f"[Source: {source}]\n{state['location_result']}"
    return {"response": response}

Wiring it together, the recovery logic becomes visible topology rather than buried control flow. The conditional edge is where primary success and failure diverge; both branches reconverge at the formatter:

def build_fallback_graph():
    builder = StateGraph(FallbackState)

    builder.add_node("primary_handler", primary_handler)
    builder.add_node("fallback_handler", fallback_handler)
    builder.add_node("response_formatter", response_formatter)

    builder.add_edge(START, "primary_handler")
    builder.add_conditional_edges(
        "primary_handler", route_after_primary,
        {"fallback_handler": "fallback_handler",
         "response_formatter": "response_formatter"}
    )
    builder.add_edge("fallback_handler", "response_formatter")
    builder.add_edge("response_formatter", END)

    return builder.compile()

Run it against “Tell me about Paris” and the primary path wins — precise coordinates, [Source: primary service]. Run it against “What can you tell me about Reykjavik, Iceland?” and the primary fails cleanly, the fallback fires, and the user still gets a useful answer marked [Source: fallback service]. No crash, no hallucinated precision, no dead end.

Why This Matters
#

Modeling failure as an explicit branch instead of a caught exception changes what you can build:

  • Tiered tool strategies: try the expensive, precise API first; fall back to a cheaper one, then to the model’s own knowledge. Each tier is a node, each downgrade is an edge.
  • Provider failover: primary LLM provider throttled or down? Route to a secondary. The graph topology makes the failover auditable instead of hidden in retry middleware.
  • Retrieval with safety nets: RAG lookup returns nothing relevant? Fall back to a broader search, then to a general answer — and tell the user the source confidence dropped.
  • Multi-agent resilience: a specialist agent declines a task? Route it to a generalist rather than failing the whole run.

The honest trade-offs:

  • Complexity creep. Every failure branch is more graph to design, test, and reason about. Don’t model recovery for failures that can’t realistically happen — you’ll drown in dead branches.
  • Silent quality degradation. A fallback that’s too graceful hides real problems. If your precise service is down 40% of the time and the fallback quietly covers for it, you won’t notice until the bill or the complaints arrive. This is exactly why the formatter stamps the source: degradation must be observable.
  • Fallbacks can fail too. The recovery path is just more code calling more services. At some point you need a terminal “honest failure” state — telling the user you couldn’t help is better than an infinite cascade of fallbacks.

Rule of thumb: make the primary path signal failure as data, route on that data, and always surface which path served the answer. Recovery you can’t see is recovery you can’t trust.


The Bigger Picture
#

This is post #12 in my series documenting Antonio Gulli’s Agentic Design Patterns. All credit for the conceptual work goes to him — I’m focused on producing clean, runnable Python implementations that you can clone, modify, and ship.

Exception Handling and Recovery is one of those patterns that feels boring until the first 3 a.m. incident. It composes with almost everything else in the series: a Router (Chapter 2) already gives you the branching machinery; Tool Use (Chapter 5) is precisely where most failures originate; and Goal Setting and Monitoring (Chapter 11) is what tells you a recovery path quietly became your primary path. The fallback graph here is the minimal, honest version of all of it.

All the code from this post is in my repository: carlosprados/Agentic_Design_Patterns, specifically under 12_Exception_Handling_and_Recovery/. The example runs with uv run, supports Gemini and Ollama via the shared get_llm() abstraction, and is ready to fork.


What’s Next
#

In the next post we’ll tackle Human-in-the-Loop — the pattern where the agent knows when not to act autonomously, and instead pauses to ask a human for a decision, an approval, or a correction. It’s the other side of recovery: some failures shouldn’t be handled by a fallback at all. They should be handed back to a person.

Stay tuned.