Error Handling#
Proper error handling is crucial for building robust GraphQL APIs. graphql-api provides several mechanisms for handling and controlling error behavior, from basic exception catching to sophisticated structured error responses.
Understanding GraphQL Errors#
GraphQL has a unique approach to error handling compared to REST APIs:
- Partial Success: Some fields can succeed while others fail
- Structured Errors: Errors include location information and can have custom extensions
- Type-based Behavior: Non-null fields vs nullable fields behave differently when errors occur
Basic Error Handling#
By default, graphql-api catches exceptions in resolvers and converts them to GraphQL errors:
from typing import Optional
@api.type(is_root_type=True)
class Root:
@api.field
def divide(self, a: float, b: float) -> float:
"""Non-null field - errors cause entire query to fail."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
@api.field
def safe_divide(self, a: float, b: float) -> Optional[float]:
"""Nullable field - errors return null for this field only."""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# Test the behavior
result = api.execute('query { divide(a: 10, b: 0) }')
# result.errors contains the ValueError, result.data is None
result = api.execute('query { safeDivide(a: 10, b: 0) }')
# result.errors contains the ValueError, result.data is {"safeDivide": None}Key behavior:
- Errors in non-null fields cause the entire query to fail (
data: null) - Errors in nullable fields return
nullfor that field but preserve other data
Partial Error Responses#
GraphQL allows partial success - some fields can succeed while others fail:
@api.type(is_root_type=True)
class Root:
@api.field
def user_data(self, error: bool = False) -> Optional[str]:
if error:
raise Exception("User data failed")
return "User data loaded"
@api.field
def settings_data(self, error: bool = False) -> str:
if error:
raise Exception("Settings failed")
return "Settings loaded"
# Query both fields with mixed success
result = api.execute('''
query {
userData(error: false)
settingsData(error: true)
}
''')
# Result will have:
# - errors: [exception from settingsData]
# - data: {"userData": "User data loaded", "settingsData": null}This allows clients to receive partial data even when some operations fail.
Error Protection Control#
You can control error protection at the API level or individual field level:
from graphql_api.context import GraphQLMetaKey
# Disable error protection globally - exceptions will propagate
api = GraphQLAPI(error_protection=False)
# Or disable for specific fields
@api.type(is_root_type=True)
class Root:
@api.field({GraphQLMetaKey.error_protection: False})
def dangerous_operation(self) -> str:
raise Exception("This will propagate!")
@api.field # This field still has error protection
def safe_operation(self) -> str:
raise Exception("This becomes a GraphQL error")When error_protection=False:
- Exceptions are not caught and will propagate to your application
- Useful for debugging or when you want to handle errors at a higher level
- Allows integration with external error handling systems
Custom Exception Classes#
Create structured exceptions for better error handling:
from graphql import GraphQLError
class UserNotFoundError(GraphQLError):
"""A specific error for when a user is not found."""
def __init__(self, user_id: int):
super().__init__(
f"User with ID {user_id} not found.",
extensions={"code": "USER_NOT_FOUND", "user_id": user_id}
)
class ValidationError(GraphQLError):
"""Error for input validation failures."""
def __init__(self, field: str, message: str):
super().__init__(
f"Validation error on field '{field}': {message}",
extensions={"code": "VALIDATION_ERROR", "field": field}
)
class PermissionError(GraphQLError):
"""Error for permission violations."""
def __init__(self, operation: str):
super().__init__(
f"Permission denied for operation: {operation}",
extensions={"code": "PERMISSION_DENIED", "operation": operation}
)
@api.type(is_root_type=True)
class Root:
@api.field
def get_user(self, user_id: int) -> User:
if user_id < 1:
raise ValidationError("user_id", "Must be positive")
user = find_user_in_db(user_id)
if not user:
raise UserNotFoundError(user_id)
return userThis provides structured error responses:
{
"errors": [
{
"message": "User with ID 123 not found.",
"locations": [...],
"path": ["getUser"],
"extensions": {
"code": "USER_NOT_FOUND",
"user_id": 123
}
}
]
}Error Categories and Best Practices#
1. Validation Errors#
Handle input validation consistently:
from pydantic import BaseModel, Field, validator
class CreateUserInput(BaseModel):
name: str = Field(min_length=2, max_length=50)
email: str
age: int = Field(ge=13, le=120)
@validator('email')
def validate_email(cls, v):
if '@' not in v:
raise ValueError('Invalid email format')
return v
@api.type(is_root_type=True)
class Root:
@api.field(mutable=True)
def create_user(self, input: CreateUserInput) -> User:
try:
# Pydantic validation happens automatically
user = User.create(input)
return user
except ValueError as e:
raise ValidationError("input", str(e))2. Authorization Errors#
Handle permission and authentication errors:
class AuthenticationError(GraphQLError):
def __init__(self):
super().__init__(
"Authentication required",
extensions={"code": "UNAUTHENTICATED"}
)
class AuthorizationError(GraphQLError):
def __init__(self, resource: str):
super().__init__(
f"Access denied to resource: {resource}",
extensions={"code": "FORBIDDEN", "resource": resource}
)
@api.type(is_root_type=True)
class Root:
@api.field
def admin_data(self, context: GraphQLContext) -> str:
user = getattr(context, 'current_user', None)
if not user:
raise AuthenticationError()
if not user.is_admin:
raise AuthorizationError("admin_data")
return "Secret admin data"3. Business Logic Errors#
Handle domain-specific errors:
class InsufficientFundsError(GraphQLError):
def __init__(self, available: float, requested: float):
super().__init__(
f"Insufficient funds: {available} available, {requested} requested",
extensions={
"code": "INSUFFICIENT_FUNDS",
"available": available,
"requested": requested
}
)
class InvalidTransactionError(GraphQLError):
def __init__(self, reason: str):
super().__init__(
f"Invalid transaction: {reason}",
extensions={"code": "INVALID_TRANSACTION", "reason": reason}
)
@api.type(is_root_type=True)
class Root:
@api.field(mutable=True)
def transfer_money(self, from_account: str, to_account: str, amount: float) -> bool:
if amount <= 0:
raise InvalidTransactionError("Amount must be positive")
account = get_account(from_account)
if account.balance < amount:
raise InsufficientFundsError(account.balance, amount)
# Perform transfer
execute_transfer(from_account, to_account, amount)
return TrueError Handling in Middleware#
Use middleware to standardize error handling across your API:
import logging
def error_handling_middleware(next_, root, info, **args):
"""Standardize error handling and logging."""
try:
return next_(root, info, **args)
except ValidationError:
# Let validation errors pass through as-is
raise
except GraphQLError:
# Let GraphQL errors pass through as-is
raise
except Exception as e:
# Log unexpected errors
logging.exception(
f"Unexpected error in {info.field_name}",
extra={
'field': info.field_name,
'args': args,
'user': getattr(info.context, 'user_id', None)
}
)
# Convert to user-friendly error
raise GraphQLError(
"An unexpected error occurred",
extensions={
"code": "INTERNAL_ERROR",
"timestamp": time.time()
}
)
api = GraphQLAPI(middleware=[error_handling_middleware])Development vs Production Error Handling#
Configure different error handling for different environments:
import os
DEBUG = os.getenv('DEBUG', 'false').lower() == 'true'
if DEBUG:
# Development: Full error details
api = GraphQLAPI(
error_protection=False, # Get full stack traces
middleware=[timing_middleware, debug_middleware]
)
else:
# Production: Sanitized errors
api = GraphQLAPI(
error_protection=True, # Hide internal errors
middleware=[auth_middleware, logging_middleware, error_handling_middleware]
)Error Context and Debugging#
Add context to errors for better debugging:
def debug_error_middleware(next_, root, info, **args):
"""Add debugging context to errors."""
try:
return next_(root, info, **args)
except GraphQLError as e:
# Add debug info to existing GraphQL errors
if DEBUG:
e.extensions = e.extensions or {}
e.extensions.update({
"debug": {
"field": info.field_name,
"path": str(info.path),
"args": args,
"timestamp": time.time()
}
})
raise
except Exception as e:
# Convert unexpected errors with debug context
error_extensions = {"code": "INTERNAL_ERROR"}
if DEBUG:
error_extensions["debug"] = {
"original_error": str(e),
"error_type": type(e).__name__,
"field": info.field_name,
"args": args
}
raise GraphQLError(
"An internal error occurred",
extensions=error_extensions
)Testing Error Scenarios#
Test your error handling thoroughly:
def test_error_handling():
# Test validation errors
result = api.execute('''
mutation {
createUser(input: {name: "", email: "invalid"}) {
id
}
}
''')
assert result.errors
assert "VALIDATION_ERROR" in str(result.errors[0])
# Test authorization errors
result = api.execute('query { adminData }') # No auth context
assert result.errors
assert "UNAUTHENTICATED" in str(result.errors[0])
# Test partial success
result = api.execute('''
query {
publicData
adminData
}
''')
assert result.errors # adminData failed
assert result.data["publicData"] # but publicData succeeded
assert result.data["adminData"] is NoneBest Practices Summary#
Structure your error hierarchy:
class APIError(GraphQLError):
"""Base class for all API errors."""
pass
class ValidationError(APIError):
"""Input validation failures."""
pass
class AuthenticationError(APIError):
"""Authentication required."""
pass
class AuthorizationError(APIError):
"""Permission denied."""
pass
class BusinessLogicError(APIError):
"""Domain-specific errors."""
passUse consistent error codes:
# Define standard error codes
ERROR_CODES = {
'VALIDATION_ERROR': 'VALIDATION_ERROR',
'UNAUTHENTICATED': 'UNAUTHENTICATED',
'FORBIDDEN': 'FORBIDDEN',
'NOT_FOUND': 'NOT_FOUND',
'INTERNAL_ERROR': 'INTERNAL_ERROR'
}Log errors appropriately:
# Log user errors at info level
logging.info(f"Validation error: {error}")
# Log system errors at error level
logging.error(f"Unexpected error: {error}", exc_info=True)Return helpful error messages:
# ✅ Good: Specific, actionable
raise ValidationError("email", "Email format is invalid")
# ❌ Avoid: Generic, unhelpful
raise Exception("Something went wrong")Well-structured error handling improves both developer experience and production reliability by providing clear, actionable error information while protecting sensitive system details.