17 KiB
17 KiB
Error Handling Guide
Comprehensive error handling for MXCP servers: SQL errors (managed by MXCP) and Python errors (YOU must handle).
Two Types of Error Handling
1. SQL Errors (Managed by MXCP)
MXCP automatically handles:
- SQL syntax errors
- Type mismatches
- Parameter binding errors
- Database connection errors
Your responsibility:
- Write correct SQL
- Use proper parameter binding (
$param) - Match return types to actual data
2. Python Errors (YOU Must Handle)
You MUST handle:
- External API failures
- Invalid input
- Resource not found
- Business logic errors
- Async/await errors
Return structured error objects, don't raise exceptions to MXCP.
Python Error Handling Pattern
❌ WRONG: Let Exceptions Bubble Up
# python/api_wrapper.py
async def fetch_user(user_id: int) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status() # ❌ Will crash if 404/500!
return response.json()
Problem: When API returns 404, exception crashes the tool. LLM gets unhelpful error.
✅ CORRECT: Return Structured Errors
# python/api_wrapper.py
import httpx
async def fetch_user(user_id: int) -> dict:
"""
Fetch user from external API.
Returns:
Success: {"success": true, "user": {...}}
Error: {"success": false, "error": "User not found", "error_code": "NOT_FOUND"}
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"https://api.example.com/users/{user_id}"
)
if response.status_code == 404:
return {
"success": False,
"error": f"User with ID {user_id} not found",
"error_code": "NOT_FOUND",
"user_id": user_id
}
if response.status_code >= 500:
return {
"success": False,
"error": "External API is currently unavailable. Please try again later.",
"error_code": "API_ERROR",
"status_code": response.status_code
}
response.raise_for_status() # Other HTTP errors
return {
"success": True,
"user": response.json()
}
except httpx.TimeoutException:
return {
"success": False,
"error": "Request timed out after 10 seconds. The API may be slow or unavailable.",
"error_code": "TIMEOUT"
}
except httpx.HTTPError as e:
return {
"success": False,
"error": f"HTTP error occurred: {str(e)}",
"error_code": "HTTP_ERROR"
}
except Exception as e:
return {
"success": False,
"error": f"Unexpected error: {str(e)}",
"error_code": "UNKNOWN_ERROR"
}
Why good:
- ✅ LLM gets clear error message
- ✅ LLM knows what went wrong (error_code)
- ✅ LLM can take action (retry, try different ID, etc.)
- ✅ Tool never crashes
Error Response Structure
Standard Error Format
{
"success": False,
"error": "Human-readable error message for LLM",
"error_code": "MACHINE_READABLE_CODE",
"details": { # Optional: additional context
"attempted_value": user_id,
"valid_range": "1-1000"
}
}
Standard Success Format
{
"success": True,
"data": {
# Actual response data
}
}
Common Error Scenarios
1. Input Validation Errors
def process_order(order_id: str, quantity: int) -> dict:
"""Process an order with validation"""
# Validate order_id format
if not order_id.startswith("ORD_"):
return {
"success": False,
"error": f"Invalid order ID format. Expected format: 'ORD_XXXXX', got: '{order_id}'",
"error_code": "INVALID_FORMAT",
"expected_format": "ORD_XXXXX",
"provided": order_id
}
# Validate quantity range
if quantity <= 0:
return {
"success": False,
"error": f"Quantity must be positive. Got: {quantity}",
"error_code": "INVALID_QUANTITY",
"provided": quantity,
"valid_range": "1 or greater"
}
if quantity > 1000:
return {
"success": False,
"error": f"Quantity {quantity} exceeds maximum allowed (1000). Please split into multiple orders.",
"error_code": "QUANTITY_EXCEEDED",
"provided": quantity,
"maximum": 1000
}
# Process order...
return {"success": True, "order_id": order_id, "quantity": quantity}
2. Resource Not Found Errors
from mxcp.runtime import db
def get_customer(customer_id: str) -> dict:
"""Get customer by ID with proper error handling"""
try:
result = db.execute(
"SELECT * FROM customers WHERE customer_id = $1",
{"customer_id": customer_id}
)
customer = result.fetchone()
if customer is None:
return {
"success": False,
"error": f"Customer '{customer_id}' not found in database. Use list_customers to see available customers.",
"error_code": "CUSTOMER_NOT_FOUND",
"customer_id": customer_id,
"suggestion": "Call list_customers tool to see all available customer IDs"
}
return {
"success": True,
"customer": dict(customer)
}
except Exception as e:
return {
"success": False,
"error": f"Database error while fetching customer: {str(e)}",
"error_code": "DATABASE_ERROR"
}
3. External API Errors
import httpx
async def create_customer_in_stripe(email: str, name: str) -> dict:
"""Create Stripe customer with comprehensive error handling"""
try:
import stripe
from mxcp.runtime import get_secret
# Get API key
secret = get_secret("stripe")
if not secret:
return {
"success": False,
"error": "Stripe API key not configured. Please set up 'stripe' secret in config.yml",
"error_code": "MISSING_CREDENTIALS",
"required_secret": "stripe"
}
stripe.api_key = secret.get("api_key")
# Create customer
customer = stripe.Customer.create(
email=email,
name=name
)
return {
"success": True,
"customer_id": customer.id,
"email": customer.email
}
except stripe.error.InvalidRequestError as e:
return {
"success": False,
"error": f"Invalid request to Stripe: {str(e)}",
"error_code": "INVALID_REQUEST",
"details": str(e)
}
except stripe.error.AuthenticationError:
return {
"success": False,
"error": "Stripe API key is invalid or expired. Please update credentials.",
"error_code": "AUTHENTICATION_FAILED"
}
except stripe.error.RateLimitError:
return {
"success": False,
"error": "Stripe rate limit exceeded. Please try again in a few seconds.",
"error_code": "RATE_LIMIT",
"suggestion": "Wait 5-10 seconds and retry"
}
except stripe.error.StripeError as e:
return {
"success": False,
"error": f"Stripe error: {str(e)}",
"error_code": "STRIPE_ERROR"
}
except ImportError:
return {
"success": False,
"error": "Stripe library not installed. Run: pip install stripe",
"error_code": "MISSING_DEPENDENCY",
"fix": "pip install stripe>=5.0.0"
}
except Exception as e:
return {
"success": False,
"error": f"Unexpected error: {str(e)}",
"error_code": "UNKNOWN_ERROR"
}
4. Business Logic Errors
def transfer_funds(from_account: str, to_account: str, amount: float) -> dict:
"""Transfer funds with business logic validation"""
# Check amount
if amount <= 0:
return {
"success": False,
"error": f"Transfer amount must be positive. Got: ${amount}",
"error_code": "INVALID_AMOUNT"
}
# Check account exists and get balance
from_balance = db.execute(
"SELECT balance FROM accounts WHERE account_id = $1",
{"account_id": from_account}
).fetchone()
if from_balance is None:
return {
"success": False,
"error": f"Source account '{from_account}' not found",
"error_code": "ACCOUNT_NOT_FOUND",
"account_id": from_account
}
# Check sufficient funds
if from_balance["balance"] < amount:
return {
"success": False,
"error": f"Insufficient funds. Available: ${from_balance['balance']:.2f}, Requested: ${amount:.2f}",
"error_code": "INSUFFICIENT_FUNDS",
"available": from_balance["balance"],
"requested": amount,
"shortfall": amount - from_balance["balance"]
}
# Perform transfer...
return {
"success": True,
"transfer_id": "TXN_12345",
"from_account": from_account,
"to_account": to_account,
"amount": amount
}
5. Async/Await Errors
import asyncio
async def fetch_multiple_users(user_ids: list[int]) -> dict:
"""Fetch multiple users concurrently with error handling"""
async def fetch_one(user_id: int) -> dict:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"https://api.example.com/users/{user_id}")
if response.status_code == 404:
return {
"user_id": user_id,
"success": False,
"error": f"User {user_id} not found"
}
response.raise_for_status()
return {
"user_id": user_id,
"success": True,
"user": response.json()
}
except asyncio.TimeoutError:
return {
"user_id": user_id,
"success": False,
"error": f"Timeout fetching user {user_id}"
}
except Exception as e:
return {
"user_id": user_id,
"success": False,
"error": str(e)
}
# Fetch all concurrently
results = await asyncio.gather(*[fetch_one(uid) for uid in user_ids])
# Separate successes and failures
successes = [r for r in results if r["success"]]
failures = [r for r in results if not r["success"]]
return {
"success": len(failures) == 0,
"total_requested": len(user_ids),
"successful": len(successes),
"failed": len(failures),
"users": [r["user"] for r in successes],
"errors": [{"user_id": r["user_id"], "error": r["error"]} for r in failures]
}
Error Messages for LLMs
Principles for Good Error Messages
- Be Specific: Tell exactly what went wrong
- Be Actionable: Suggest what to do next
- Provide Context: Include relevant values/IDs
- Use Plain Language: Avoid technical jargon
❌ BAD Error Messages
return {"error": "Error"} # ❌ Useless
return {"error": "Invalid input"} # ❌ Which input? Why invalid?
return {"error": "DB error"} # ❌ What kind of error?
return {"error": str(e)} # ❌ Raw exception message (often cryptic)
✅ GOOD Error Messages
return {
"error": "Customer ID 'CUST_999' not found. Use list_customers to see available IDs."
}
return {
"error": "Date format invalid. Expected 'YYYY-MM-DD' (e.g., '2024-01-15'), got: '01/15/2024'"
}
return {
"error": "Quantity 5000 exceeds maximum allowed (1000). Split into multiple orders or contact support."
}
return {
"error": "API rate limit exceeded. Please wait 30 seconds and try again."
}
SQL Error Handling (MXCP Managed)
You Don't Handle These (MXCP Does)
MXCP automatically handles and returns errors for:
- Invalid SQL syntax
- Missing tables/columns
- Type mismatches
- Parameter binding errors
Your job: Write correct SQL and let MXCP handle errors.
Prevent SQL Errors
1. Validate Schema
# Always define return types to match SQL output
tool:
name: get_stats
return:
type: object
properties:
total: { type: number } # Matches SQL: SUM(amount)
count: { type: integer } # Matches SQL: COUNT(*)
source:
code: |
SELECT
SUM(amount) as total,
COUNT(*) as count
FROM orders
2. Handle NULL Values
-- BAD: Might return NULL which breaks type system
SELECT amount FROM orders WHERE id = $order_id
-- GOOD: Handle potential NULL
SELECT COALESCE(amount, 0) as amount
FROM orders
WHERE id = $order_id
-- GOOD: Use IFNULL/COALESCE for aggregations
SELECT
COALESCE(SUM(amount), 0) as total,
COALESCE(AVG(amount), 0) as average
FROM orders
WHERE status = $status
3. Handle Empty Results
-- If no results, return empty array (not NULL)
SELECT * FROM customers WHERE city = $city
-- Returns: [] if no customers (MXCP handles this)
-- For aggregations, always return a row
SELECT
COUNT(*) as count,
COALESCE(SUM(amount), 0) as total
FROM orders
WHERE status = $status
-- Always returns one row, even if no matching orders
Error Codes Convention
Use consistent error codes across your tools:
# Standard error codes
ERROR_CODES = {
# Input validation
"INVALID_FORMAT": "Input format is incorrect",
"INVALID_RANGE": "Value outside valid range",
"MISSING_REQUIRED": "Required parameter missing",
# Resource errors
"NOT_FOUND": "Resource not found",
"ALREADY_EXISTS": "Resource already exists",
"DELETED": "Resource has been deleted",
# Permission errors
"UNAUTHORIZED": "User not authenticated",
"FORBIDDEN": "User lacks permission",
# External service errors
"API_ERROR": "External API error",
"TIMEOUT": "Request timed out",
"RATE_LIMIT": "Rate limit exceeded",
# System errors
"DATABASE_ERROR": "Database operation failed",
"CONFIGURATION_ERROR": "Missing or invalid configuration",
"DEPENDENCY_ERROR": "Required library not installed",
# Business logic errors
"INSUFFICIENT_FUNDS": "Not enough balance",
"INVALID_STATE": "Operation not allowed in current state",
"QUOTA_EXCEEDED": "Usage quota exceeded",
# Unknown
"UNKNOWN_ERROR": "Unexpected error occurred"
}
Testing Error Handling
Unit Tests for Error Cases
# tests/test_error_handling.py
import pytest
from python.my_module import fetch_user
@pytest.mark.asyncio
async def test_fetch_user_not_found(httpx_mock):
"""Test 404 error handling"""
httpx_mock.add_response(
url="https://api.example.com/users/999",
status_code=404
)
result = await fetch_user(999)
assert result["success"] is False
assert result["error_code"] == "NOT_FOUND"
assert "999" in result["error"] # Error mentions the ID
@pytest.mark.asyncio
async def test_fetch_user_timeout(httpx_mock):
"""Test timeout handling"""
httpx_mock.add_exception(httpx.TimeoutException("Timeout"))
result = await fetch_user(123)
assert result["success"] is False
assert result["error_code"] == "TIMEOUT"
assert "timeout" in result["error"].lower()
def test_invalid_input():
"""Test input validation"""
result = process_order("INVALID", quantity=5)
assert result["success"] is False
assert result["error_code"] == "INVALID_FORMAT"
assert "ORD_" in result["error"] # Mentions expected format
Error Handling Checklist
Before declaring Python tool complete:
- All external API calls wrapped in try/except
- All exceptions return structured error objects
- Error messages are clear and actionable
- Error codes are consistent
- Input validation with helpful error messages
- NULL/None values handled gracefully
- Timeout handling for network calls
- Missing dependencies handled (ImportError)
- Database errors caught and explained
- Success/failure clearly indicated in response
- Unit tests for error scenarios
- Error messages help LLM understand what to do next
Summary
SQL Tools (MXCP Handles):
- Write correct SQL
- Handle NULL values with COALESCE
- Match return types to SQL output
Python Tools (YOU Handle):
- ✅ Wrap ALL external calls in try/except
- ✅ Return structured error objects (
{"success": False, "error": "...", "error_code": "..."}) - ✅ Validate inputs with clear error messages
- ✅ Be specific and actionable in error messages
- ✅ Use consistent error codes
- ✅ Test error scenarios
- ✅ NEVER let exceptions bubble up to MXCP
Golden Rule: Errors should help the LLM understand what went wrong and what to do next.