Skip to main content
Lesson 10 of 14 · Function Calling and Tool Use

Error Handling

Lesson 10 of 14 Updated November 2, 2026 Developer
On this page
  1. Errors Are Inevitable
  2. Never Hide an Error from the Model
  3. An Executor That Lets Nothing Slip Through
  4. Distinguish Transient from Permanent Failures
  5. The Mistral API Can Also Fail
  6. See What’s Actually Happening
  7. Key Takeaways

Errors Are Inevitable

In production, functions fail. External APIs go down, IDs don’t exist, parameters are invalid, and timeouts expire. The question isn’t “will it fail?” but “how do we handle failure gracefully?” A payment tracker that crashes because a user typed T9999 instead of T1001 isn’t a production system; it’s a demo.

The good news is that you have a resource here that classic architectures lack: the Mistral model can understand error messages you send back and adapt its response accordingly. Where a traditional backend must craft a UI message for every error code, you can let the model translate the failure into a helpful sentence for the user. You just need to give it the information.

Never Hide an Error from the Model

This is the golden rule of this lesson. Return an error in the tool message exactly as you would a normal result: as structured JSON. The opposite reflex—catching the exception and returning an empty string or null—makes the model believe the function succeeded but simply had nothing to say. This produces the most confusing responses possible.

def retrieve_payment_status(df, transaction_id: str) -> str:
    """Retrieves status with full error handling."""
    try:
        if transaction_id not in df["transaction_id"].values:
            return json.dumps({
                "error": "transaction_not_found",
                "message": f"Transaction {transaction_id} does not exist in the database."
            })

        status = df[df["transaction_id"] == transaction_id]["payment_status"].values[0]
        return json.dumps({"status": status, "transaction_id": transaction_id})

    except Exception as e:
        return json.dumps({
            "error": "internal_error",
            "message": f"Error retrieving data: {str(e)}"
        })

Two fields are enough: a machine code in error and a human-readable explanation in message. The code lets your monitoring system count occurrences; the explanation lets the model formulate a helpful response. See what happens with a non-existent transaction.

# User requests a non-existent transaction
messages = [{"role": "user", "content": "Status of payment T9999?"}]

# Model calls retrieve_payment_status(transaction_id="T9999")
# Your function returns: {"error": "transaction_not_found", ...}

# Model formulates:
# → "I couldn't find a transaction with ID T9999.
#     Could you check the number and try again?"

No one wrote that sentence. It stems from the error code, and it even suggests a corrective action to the user.

An Executor That Lets Nothing Slip Through

Protecting a function isn’t enough: the layer dispatching tool_calls has its own failure modes, independent of your business logic. The model might hallucinate a function name that doesn’t exist in your dictionary. It might produce malformed JSON arguments—a rare but real case with complex schemas. It might also provide valid JSON whose keys don’t match the Python signature, causing a TypeError during **function_params. Each of these three cases deserves its own error code because each requires a different fix on your end.

def execute_tool_call(tool_call, available_functions):
    """Executes a tool_call with full error handling."""
    function_name = tool_call.function.name

    # Check if function exists
    if function_name not in available_functions:
        return {
            "role": "tool",
            "name": function_name,
            "content": json.dumps({
                "error": "function_not_found",
                "message": f"Function {function_name} is not available."
            }),
            "tool_call_id": tool_call.id
        }

    # Parse arguments
    try:
        function_params = json.loads(tool_call.function.arguments)
    except json.JSONDecodeError as e:
        return {
            "role": "tool",
            "name": function_name,
            "content": json.dumps({
                "error": "invalid_arguments",
                "message": f"Invalid JSON arguments: {str(e)}"
            }),
            "tool_call_id": tool_call.id
        }

    # Execute function
    try:
        result = available_functions[function_name](**function_params)
        return {
            "role": "tool",
            "name": function_name,
            "content": result,
            "tool_call_id": tool_call.id
        }
    except TypeError as e:
        return {
            "role": "tool",
            "name": function_name,
            "content": json.dumps({
                "error": "invalid_params",
                "message": f"Incompatible parameters: {str(e)}"
            }),
            "tool_call_id": tool_call.id
        }
    except Exception as e:
        return {
            "role": "tool",
            "name": function_name,
            "content": json.dumps({
                "error": "execution_error",
                "message": f"Execution error: {str(e)}"
            }),
            "tool_call_id": tool_call.id
        }

Notice that every branch, including failure branches, returns a complete tool message with its tool_call_id. This is essential: the API rejects a history where a tool_call goes unanswered, and a silent failure would cause a validation error on the next turn, far from the real cause.

Distinguish Transient from Permanent Failures

Not all errors are equal. A missing transaction will still be missing on the second try; retrying just wastes time. A network timeout or a momentarily saturated service, however, often resolves itself. For this second category, a retry with exponential backoff—one second, then two, then four—gives the service time to recover without hammering it with requests. After exhausting attempts, you fall back to the usual behavior: an error JSON destined for the model.

import time

def execute_with_retry(func, params, max_retries=3, base_delay=1.0):
    """Executes a function with exponential retry."""
    for attempt in range(max_retries):
        try:
            result = func(**params)
            return result
        except ConnectionError as e:
            if attempt < max_retries - 1:
                delay = base_delay * (2 ** attempt)
                time.sleep(delay)
            else:
                return json.dumps({
                    "error": "service_unavailable",
                    "message": f"Service unavailable after {max_retries} attempts.",
                    "details": str(e)
                })

The Mistral API Can Also Fail

Everything above concerns your functions. The call to the Mistral API itself is a distinct failure point, handled separately: here, there’s no model to explain the problem to, since the model is precisely what’s unreachable. An HTTPValidationError usually signals an error in your message or tools structure, which you must fix in code; other exceptions relate to the network or service and are handled with the same retry logic as above.

from mistralai.client import Mistral
from mistralai.client.errors import HTTPValidationError

def safe_chat_complete(client, model, messages, tools):
    """Mistral API call with error handling."""
    try:
        return client.chat.complete(
            model=model,
            messages=messages,
            tools=tools
        )
    except HTTPValidationError as e:
        print(f"Validation error: {e}")
        return None
    except Exception as e:
        print(f"API error: {e}")
        return None

See What’s Actually Happening

A function calling system is hard to debug after the fact if you don’t know which function was called, with what arguments, and what it returned. Logging isn’t a luxury; it’s how you’ll discover that an ambiguous description pushes the model to confuse two functions, or that one in five users systematically triggers a transaction_not_found. Log the call before execution, then the verdict, distinguishing info level from error level so your alerts can filter effectively.

import logging

logger = logging.getLogger("function_calling")

def execute_and_log(tool_call, available_functions):
    """Executes and logs the result."""
    function_name = tool_call.function.name
    function_params = json.loads(tool_call.function.arguments)

    logger.info(f"Call: {function_name}({function_params})")

    result = execute_tool_call(tool_call, available_functions)
    content = json.loads(result["content"])

    if "error" in content:
        logger.error(f"Failure {function_name}: {content['error']} - {content['message']}")
    else:
        logger.info(f"Success {function_name}: {content}")

    return result

Key Takeaways

  • Always return errors to the model in the tool message—it knows how to interpret them
  • Use structured error codes (transaction_not_found, invalid_arguments, etc.)
  • Implement retry with backoff for transient errors
  • Handle errors in your functions and errors in the Mistral API separately
  • Log every call and every error for production monitoring