Recall Module 24’s .with_retry() — a real, useful fix, applied to a single model call. This module covers something broader: making failure recovery a genuine, visible part of a graph’s own structure — real routing, real state, real fallback paths — for the honest range of things that actually go wrong in a production workflow.
The shape, drawn first
flowchart TD
A[Call the API] --> B{Success?}
B -->|Yes| C[Continue]
B -->|No| D{Retry count < 3?}
D -->|Yes| E[Increment count, retry] --> A
D -->|No| F[Fallback path]
This is genuinely Module 13’s loop structure, applied to failure recovery instead of quality revision — the same real shape, a different real reason for looping.
Example 1: a node exception, handled as graph-level retry
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
order_id: str
result: str
retry_count: int
def call_payment_api(state: State) -> dict:
if state["retry_count"] < 2: # simulating two real failures before success
raise RuntimeError("Payment API temporarily unavailable.")
return {"result": "Payment processed successfully."}
def handle_failure(state: State) -> dict:
return {"retry_count": state["retry_count"] + 1}
def route(state: State) -> str:
return "END"
builder = StateGraph(State)
builder.add_node("call_payment_api", call_payment_api)
builder.add_edge(START, "call_payment_api")
builder.add_edge("call_payment_api", END)
graph = builder.compile()
Recall that a raised exception, left unhandled, crashes the whole graph, exactly like Module 24’s opening example. The real, graph-level fix wraps the risky call and turns failure into genuine, routable state:
def call_payment_api(state: State) -> dict:
try:
if state["retry_count"] < 2:
raise RuntimeError("Payment API temporarily unavailable.")
return {"result": "Payment processed successfully.", "succeeded": True}
except RuntimeError:
return {"retry_count": state["retry_count"] + 1, "succeeded": False}
def route_after_payment(state: State) -> str:
if state.get("succeeded"):
return "END"
if state["retry_count"] >= 3:
return "fallback"
return "call_payment_api"
This is precisely Module 13’s bounded-loop discipline, now protecting against a real, genuine technical failure rather than a quality judgment.
Example 2: LLM timeout, handled explicitly
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4o-mini", timeout=10)
def call_model_node(state: State) -> dict:
try:
response = model.invoke(state["order_id"])
return {"result": response.content, "succeeded": True}
except TimeoutError:
return {"result": "The model took too long to respond.", "succeeded": False}
Recall timeout from your LangChain course’s own chat model parameters — a real, genuine timeout raises a real, catchable exception, and the node’s job is turning that into state the rest of the graph can route on, exactly like Example 1’s payment failure.
Example 3: invalid structured output
from pydantic import BaseModel, ValidationError
from langchain.chat_models import init_chat_model
class Extraction(BaseModel):
amount: float
structured_model = init_chat_model("openai:gpt-4o-mini").with_structured_output(Extraction)
def extract_amount(state: State) -> dict:
try:
result = structured_model.invoke(f"Extract the dollar amount from: {state['order_id']}")
return {"result": str(result.amount), "succeeded": True}
except ValidationError:
return {"result": "Could not extract a valid amount.", "succeeded": False}
Recall Module 18 (LangChain course)‘s own honest warning — structured output can genuinely fail validation. Here, that failure becomes real, visible state, rather than an unhandled crash burying the rest of the graph’s ability to respond sensibly.
Example 4: retriever error, falling back to a direct answer
def retrieve(state: State) -> dict:
try:
# a real vector store call, from your RAG course, could genuinely time out or error
raise ConnectionError("Vector store unreachable.")
except ConnectionError:
return {"succeeded": False}
def answer_without_context(state: State) -> dict:
return {"result": "I don't have access to our knowledge base right now, but here's a general answer..."}
def route_after_retrieval(state: State) -> str:
return "generate_with_context" if state.get("succeeded") else "answer_without_context"
Recall Module 8’s own RAG-vs-direct routing scenario — this is the exact same real pattern, now triggered by a genuine failure rather than a deliberate choice. A retriever going down shouldn’t mean the whole workflow goes down with it; a degraded, honest answer is almost always better than no answer at all.
Example 5: human rejection — a genuine outcome, not a technical failure
Recall Module 20’s Pattern 2 directly — rejection is worth handling distinctly from the technical failures above, since it’s a legitimate, real decision, not something to “recover” from in the same sense.
def route_after_approval(state: State) -> str:
if state["decision"] == "approve":
return "execute"
if state["decision"] == "reject":
return "notify_rejection"
return "escalate"
def notify_rejection(state: State) -> dict:
return {"result": f"Action was reviewed and rejected: {state.get('reason', 'no reason given')}"}
Notice notify_rejection isn’t a fallback in the same sense as Examples 1-4 — it’s a genuinely valid, intended outcome of the workflow, not a degraded response to something going wrong technically. Worth keeping these two categories — technical failure and legitimate human rejection — conceptually distinct in your own graph’s design.
Example 6: real fallback as routing — model, tool, and workflow level
from langchain.chat_models import init_chat_model
primary_model = init_chat_model("openai:gpt-4o-mini")
backup_model = init_chat_model("google_genai:gemini-2.0-flash")
def call_primary(state: State) -> dict:
try:
return {"result": primary_model.invoke(state["order_id"]).content, "succeeded": True}
except Exception:
return {"succeeded": False}
def call_backup(state: State) -> dict:
return {"result": backup_model.invoke(state["order_id"]).content}
def route(state: State) -> str:
return "END" if state.get("succeeded") else "call_backup"
builder = StateGraph(State)
builder.add_node("call_primary", call_primary)
builder.add_node("call_backup", call_backup)
builder.add_edge(START, "call_primary")
builder.add_conditional_edges("call_primary", route, {"END": END, "call_backup": "call_backup"})
builder.add_edge("call_backup", END)
graph = builder.compile()
This is genuinely the graph-level version of .with_fallbacks() from your LangChain course — the exact same real intent, expressed as visible, explicit routing rather than a wrapped method call. The real, practical benefit: a graph-level fallback can be a genuinely different node — a different tool, a completely different workflow path, a human escalation — not only a different model.
Why this module’s failure modes are the genuine norm, not the exception
It’s worth grounding this module’s entire premise in real, current data, because “handle failure gracefully” can sound like defensive over-engineering rather than a genuine, everyday requirement. A real, documented analysis of production LLM deployments across more than 650 real organizations found that provider outages, rate limits, timeouts, and latency spikes aren’t edge cases at all — they happen routinely enough to directly disrupt real business workflows and erode real user trust. The same analysis made a point worth remembering precisely: even a provider boasting “99.99% uptime” still genuinely accounts for about 52 minutes of real downtime every year — more than enough time for a real, unhandled failure to reach real users. This is exactly why real, production teams processing meaningful traffic almost always hedge across more than one provider, exactly like this module’s Example 6.
Common mistakes worth avoiding
Treating human rejection as if it were a technical failure needing retry. Recall Example 5 — retrying a genuine, deliberate human decision by asking again is usually the wrong response; the correct handling is respecting and acting on that real decision.
Letting succeeded flags accumulate without ever being reset. A succeeded: False from one node, never explicitly reset, can silently leak into later, unrelated routing decisions if your state design isn’t deliberate about which fields belong to which specific step.
Wrapping every single node in try/except defensively, even ones with genuinely low failure risk. Recall Module 12 (LangChain course)‘s own lesson about over-engineering — a pure, deterministic Python transformation node, from Module 4, rarely needs the same defensive wrapping as a real network call or model invocation.
What you should take away from this module
- Node exceptions, timeouts, invalid structured output, and retriever errors are all handled the same real way: catch the failure inside the node, convert it into genuine, routable state.
- Retry, at the graph level, is Module 13’s bounded loop, applied to technical failure recovery.
- Fallback, at the graph level, is real, explicit routing to an alternative node — a different model, a different tool, or a different workflow entirely — not just a wrapped method call.
- Human rejection is a legitimate, real outcome, worth handling distinctly from technical failure, not retried as if it were one.
Where this goes next
The next module covers Streaming, Observability, and Debugging — watching a graph’s real, live execution, and answering the genuine, practical question every one of this module’s failure modes eventually raises: which node actually ran, what state went in, and why.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed