How to Handle Errors in Custom LangChain Tools (with Middleware)

Why handle_tool_error stops working the moment your LangChain agent has middleware attached, and how to catch tool errors with wrap_tool_call or ToolRetryMiddleware instead.

How to Handle Errors in Custom LangChain Tools (with Middleware) - AutomateLab title card
Why handle_tool_error stops working once middleware is attached, and what to use instead.

TL;DR: Raise ToolException and set handle_tool_error for classic LangChain agents, but middleware on create_agent silently ignores that setting - catch failures yourself in a wrap_tool_call hook or attach ToolRetryMiddleware instead.

This bites teams migrating from the classic AgentExecutor to LangChain 1.0's create_agent, where any middleware - even one added only for logging or rate limits - defeats the handle_tool_error contract without a warning. GitHub issue #33153 documents the exact failure, closed as not planned, so there is no framework-level fix coming. Hit a different LangChain error on the same project? Browse the Automation Error Index for a searchable catalog of LangChain and LangGraph errors, each with a fix.

Why does a custom LangChain tool crash the whole agent run?

By default, an unhandled exception inside a tool's function body propagates straight up through the agent loop and stops the run. A tool that raises a plain ValueError on bad input, or that calls a flaky third-party API and gets back malformed JSON, takes the whole conversation down with it. GitHub issue #15317 is the oldest documented case of this: a custom agent's math tool raised a ValueError the moment the model passed None as an argument, and the chatbot crashed mid-conversation. It was closed as not planned - there has never been a framework-level catch-all here. Handling a tool's own errors has always been the tool author's job, first through ToolException, and now also through middleware.

How do you raise a ToolException in a custom tool?

The classic pattern still works on any agent that has no middleware attached. Raise ToolException inside the tool body, then opt the tool into error handling:

from langchain_core.tools import tool, ToolException

KNOWN_CITIES = {"nyc", "london", "tokyo"}

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    if city.lower() not in KNOWN_CITIES:
        raise ToolException(f"Unknown city: {city}")
    return fetch_weather(city)

get_weather.handle_tool_error = True

With handle_tool_error set to True, the agent's executor catches the ToolException, turns it into an observation the model can read, and lets the model retry with a different argument instead of crashing the run. Set it to a callable instead of True to control exactly what the model sees:

def _format_tool_error(error: ToolException) -> str:
    return f"Tool error: {error}. Check the input and retry."

get_weather.handle_tool_error = _format_tool_error

Why does handle_tool_error stop working once you add middleware?

This is the part most existing tutorials miss, because they were written before LangChain 1.0's middleware system existed. Per #33153, a ToolNode(handle_tool_errors=...) configuration works correctly on its own, but the moment middleware=[...] is passed to create_agent, the custom handler is silently ignored and the agent falls back to default error handling - no exception, no log line, just different behavior than the code implies. The bug was filed against langchain 1.0.0a9 and remains closed as not planned in the 1.0 release, so it is current behavior, not a temporary regression.

That failure mode is also worth separating from a case where AgentExecutor never calls the tool at all - there, no exception is raised anywhere, the model just never attempts the call. What's described here is the opposite: the tool does get called, it does raise, and the handling you configured for that exception simply never runs once middleware is in the picture.

Flow diagram: a custom tool raises ToolException, then a decision diamond asks whether the agent has middleware attached. No middleware routes to handle_tool_error catching it. Middleware attached routes to handle_tool_error being silently ignored, then to catching the error with wrap_tool_call or ToolRetryMiddleware.
The presence of any middleware on create_agent is what decides whether handle_tool_error still works.

How do you catch tool errors with a wrap_tool_call middleware?

Once middleware is on the agent, move error handling into a wrap_tool_call hook. It runs around each tool call and receives the request plus a handler you call to execute it:

from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain.messages import ToolMessage

@wrap_tool_call
def handle_tool_errors(request, handler):
    try:
        return handler(request)
    except Exception as e:
        return ToolMessage(
            content=f"Tool error: Please check your input and try again. ({e})",
            tool_call_id=request.tool_call["id"],
        )

agent = create_agent(
    model="gpt-5.5",
    tools=[get_weather],
    middleware=[handle_tool_errors],
)

The handler can be invoked zero times, once, or multiple times for retry logic, and each call is independent and stateless. Never use yield inside a wrap_tool_call hook - it turns the function into a generator and raises NotImplementedError instead of running your logic.

How do you add automatic retries with ToolRetryMiddleware?

For transient failures - a timeout, a dropped connection, a 503 from an upstream API - LangChain ships a built-in ToolRetryMiddleware instead of making you hand-roll a retry loop:

from langchain.agents import create_agent
from langchain.agents.middleware import ToolRetryMiddleware

agent = create_agent(
    model="gpt-5.5",
    tools=[get_weather, query_database],
    middleware=[
        ToolRetryMiddleware(
            max_retries=3,
            tools=["query_database"],
            retry_on=(ConnectionError, TimeoutError),
            on_failure="return_message",
        ),
    ],
)

max_retries defaults to 2 (three total attempts). tools scopes the retry logic to specific tool names instead of every tool on the agent. backoff_factor (default 2.0) and initial_delay (default 1.0 second) control the exponential wait between attempts, capped by max_delay (default 60 seconds), and jitter (default True) adds +/-25% randomness so multiple agents retrying the same failing API don't all hammer it in lockstep. When retries run out, on_failure decides what happens next: "return_message" hands the model a ToolMessage so it can try a different approach, "raise" stops the agent immediately, or a callable can format a custom message.

Tune max_retries against the agent's own patience: three silent retries on every tool call just delay the same agent stopped due to iteration limit failure if the agent was already close to its cap.

Timeline showing ToolRetryMiddleware's default three-attempt sequence: attempt 1 fails at t=0, waits about 1.0 second, attempt 2 fails, waits about 2.0 seconds under the default backoff_factor of 2.0, attempt 3 fails, then on_failure decides between return_message and raise.
With the defaults, three failed attempts and two backoff waits happen before on_failure ever runs.

Can you combine ToolRetryMiddleware with a custom wrap_tool_call handler?

Yes - list both in the same middleware=[...] argument. ToolRetryMiddleware absorbs the transient, retry-worthy failures (timeouts, connection resets), and a custom wrap_tool_call handler catches whatever is left - validation errors, business-logic exceptions, anything that a retry wouldn't fix anyway - and turns it into a ToolMessage the model can act on. Keep the two responsibilities separate: retry logic belongs to ToolRetryMiddleware, message formatting belongs to your own hook. That split is also why this problem is distinct from legacy output parsing errors - those come from the old free-text ReAct loop failing to parse a response, not from a tool raising an exception, and no amount of wrap_tool_call or retry middleware touches that failure class.

How do you pick the right error-handling pattern in under a minute?

  1. Confirm whether the agent is built with create_agent and has any middleware attached.
  2. If there is no middleware, raise ToolException in the tool body and set handle_tool_error to True or a formatter function.
  3. If middleware is attached, move error handling into a wrap_tool_call hook that catches the exception and returns a ToolMessage.
  4. Attach ToolRetryMiddleware for transient failures, scoping max_retries and backoff_factor to the tool's own rate limits.
  5. List ToolRetryMiddleware and the custom wrap_tool_call handler together in the same middleware list so retries and message formatting both apply.

FAQ

What is ToolException and when should I raise it?

ToolException is the exception LangChain expects a tool to raise for a recoverable, tool-level failure - bad input, an unknown lookup key, a validation error - as opposed to letting a generic Python exception propagate uncaught.

What does handle_tool_error actually do on a LangChain tool?

It tells the agent's executor to catch a raised ToolException and convert it into an observation the model can read, instead of letting the exception stop the run. Set it to True for the default message or to a callable for a custom one.

Why does my custom error handler stop working when I add middleware?

Attaching any middleware to a create_agent-built agent causes the classic handle_tool_error / ToolNode(handle_tool_errors=...) setting to be silently ignored, per GitHub issue #33153. Move the same logic into a wrap_tool_call hook instead.

What is ToolRetryMiddleware and when should I use it instead of handle_tool_error?

It is a built-in middleware that automatically retries a failed tool call with exponential backoff. Use it for transient, infrastructure-level failures like timeouts and dropped connections; use wrap_tool_call or handle_tool_error for failures that a retry won't fix, like bad input.

Can I combine ToolRetryMiddleware with a custom wrap_tool_call handler?

Yes. List both in the agent's middleware=[...] argument - ToolRetryMiddleware handles the retry-worthy failures and your custom hook handles everything else.

Does wrap_tool_call work the same way in LangChain.js?

LangChain.js has the same middleware shape - a handler you wrap in a try/catch and, on failure, return a ToolMessage (or a Command wrapping one) carrying the original tool_call_id - though the exact class names differ slightly from the Python API.