636 lines
17 KiB
Markdown
636 lines
17 KiB
Markdown
# 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
|
|
# 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
|
|
# 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
|
|
|
|
```python
|
|
{
|
|
"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
|
|
|
|
```python
|
|
{
|
|
"success": True,
|
|
"data": {
|
|
# Actual response data
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Error Scenarios
|
|
|
|
### 1. Input Validation Errors
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
1. **Be Specific**: Tell exactly what went wrong
|
|
2. **Be Actionable**: Suggest what to do next
|
|
3. **Provide Context**: Include relevant values/IDs
|
|
4. **Use Plain Language**: Avoid technical jargon
|
|
|
|
### ❌ BAD Error Messages
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```yaml
|
|
# 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
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```sql
|
|
-- 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**:
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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.
|