Error Handling Patterns - Deep Dive¶
This guide dives deep into FraiseQL's error handling philosophy, patterns, and advanced usage. It explains why we have one opinionated way and how to use it effectively.
Philosophy: Why One Pattern?¶
FraiseQL takes an opinionated stance on error handling to provide consistency and developer experience:
Problems with Multiple Patterns¶
Before (v1.7.x):
field_errors: dict[str, str]in blog_simplevalidation_errors: list[dict]in enterpriseMutationResultBaserequired for errors- Ad-hoc patterns everywhere
- Inconsistent API responses
Result: Confusion, scattered documentation, poor DX
The FraiseQL Way™¶
After (v1.8.1+):
errors: list[Error]on ALL error responses- Auto-populated from status strings
- No special base classes needed
- One pattern, everywhere
Benefits:
- ✅ Predictable API responses
- ✅ Consistent error structure
- ✅ Better TypeScript/frontend integration
- ✅ Single source of truth
- ✅ Zero boilerplate for simple cases
Response Structure Design¶
Error responses have a two-level structure by intentional design. Understanding this helps you use the API effectively.
Root Level Fields (Quick Access)¶
{
"code": 422, // HTTP-like status for quick checks
"status": "validation:", // Domain-specific status string
"message": "Name is required", // Human-readable summary
"errors": [...] // Detailed structured errors (see below)
}
Use root fields when:
- Quick error checks:
if (response.code === 422) { ... } - Display single error message:
toast.error(response.message) - Legacy client compatibility
- You need the overall status:
response.status === "validation:"
Errors Array (Structured Details)¶
{
"errors": [
{
"code": 422, // Same as root for consistency
"identifier": "validation", // Extracted from status string
"message": "Name is required",// Field-specific or same as root
"details": {"field": "name"} // Optional structured context
}
]
}
Use errors array when:
- Multiple validation errors to display
- Mapping errors to specific form fields
- Structured error processing:
errors.forEach(err => ...) - You need the machine-readable identifier
- You need error-specific details
Why Both Root and Array?¶
This design supports both simple and complex use cases:
Simple case (single error):
{
"message": "Name is required", // ← Quick access
"errors": [{
"message": "Name is required", // ← Same value, structured format
"identifier": "validation"
}]
}
Root and errors[0] intentionally match for convenience.
Complex case (multiple errors):
{
"message": "Multiple validation errors", // ← Summary
"errors": [
{"identifier": "invalid_email", "message": "Email format invalid"},
{"identifier": "password_weak", "message": "Password too short"}
]
}
Root provides summary, array provides per-field details.
Quick Reference¶
| Need | Use |
|---|---|
| Check if error | response.code >= 400 |
| Show toast notification | response.message |
| Get error category | response.status prefix |
| Loop through errors | response.errors.forEach(...) |
| Map to form field | response.errors.find(e => e.details.field === 'email') |
| Machine-readable ID | response.errors[0].identifier |
Auto-Generated Errors (Default)¶
The simplest and most common pattern: status strings automatically become structured errors.
How It Works¶
- PostgreSQL returns status string:
"validation:" - Rust pipeline extracts identifier:
"validation" - GraphQL response includes:
errors: [{code: 422, identifier: "validation", ...}]
Status String Format¶
{prefix}:{identifier}
Prefixes:
failed:- General errors (422)not_found:- Missing resources (404)conflict:- Business conflicts (409)unauthorized:- Auth issues (401)forbidden:- Permission issues (403)timeout:- Timeouts (408)noop:- No changes (422)
Examples:
-- Input validation
status := 'validation:'
-- User not found
status := 'not_found:user'
-- Email conflict
status := 'conflict:duplicate_email'
-- Permission denied
status := 'forbidden:insufficient_role'
Auto-Generated Error Structure¶
{
"code": 422,
"identifier": "validation",
"message": "Email format invalid",
"details": null
}
Code mapping:
failed:*→ 422 (Unprocessable Entity)not_found:*→ 404 (Not Found)conflict:*→ 409 (Conflict)unauthorized:*→ 401 (Unauthorized)forbidden:*→ 403 (Forbidden)timeout:*→ 408 (Request Timeout)noop:*→ 422 (Unprocessable Entity)
Explicit Errors (Advanced)¶
For complex validation with multiple field-level errors, use metadata.errors.
When to Use¶
- Multiple validation errors per field
- Field-level error details
- Custom error codes per error
- Complex validation logic
PostgreSQL Implementation¶
CREATE OR REPLACE FUNCTION create_user(input_payload jsonb)
RETURNS mutation_response AS $$
DECLARE
result mutation_response;
validation_errors jsonb := '[]'::jsonb;
BEGIN
-- Collect validation errors
IF input_payload->>'email' IS NULL THEN
validation_errors := validation_errors || jsonb_build_object(
'code', 422,
'identifier', 'email_required',
'message', 'Email address is required',
'details', jsonb_build_object(
'field', 'email',
'constraint', 'required',
'severity', 'error'
)
);
END IF;
IF input_payload->>'name' IS NULL THEN
validation_errors := validation_errors || jsonb_build_object(
'code', 422,
'identifier', 'name_required',
'message', 'Full name is required',
'details', jsonb_build_object(
'field', 'name',
'constraint', 'required'
)
);
END IF;
-- Email format validation
IF input_payload->>'email' NOT LIKE '%@%' THEN
validation_errors := validation_errors || jsonb_build_object(
'code', 422,
'identifier', 'email_invalid_format',
'message', 'Email must contain @ symbol',
'details', jsonb_build_object(
'field', 'email',
'constraint', 'format',
'provided_value', input_payload->>'email'
)
);
END IF;
-- Return errors if any
IF jsonb_array_length(validation_errors) > 0 THEN
result.status := 'validation:';
result.message := format('Validation failed with %s errors',
jsonb_array_length(validation_errors));
result.metadata := jsonb_build_object('errors', validation_errors);
RETURN result;
END IF;
-- Success case...
END;
$$ LANGUAGE plpgsql;
Explicit Error Structure¶
{
"errors": [
{
"code": 422,
"identifier": "email_required",
"message": "Email address is required",
"details": {
"field": "email",
"constraint": "required",
"severity": "error"
}
},
{
"code": 422,
"identifier": "email_invalid_format",
"message": "Email must contain @ symbol",
"details": {
"field": "email",
"constraint": "format",
"provided_value": "invalid-email"
}
}
]
}
Error Type Definition¶
All errors conform to this structure:
@fraise_type
class Error:
code: int # HTTP-like status code
identifier: str # Machine-readable error ID
message: str # Human-readable message
details: Any | None # Optional structured details
Field Meanings¶
code: HTTP-inspired status codes for categorization:
- 400-499: Client errors (validation, not found, etc.)
- 500-599: Server errors
identifier: Machine-readable identifier for:
- Frontend error handling logic
- Translation keys
- Analytics tracking
- Debugging
message: Human-readable description for:
- User display
- Logs
- API documentation
details: Structured additional context:
- Field names for validation errors
- Constraint information
- Debug data
- Recovery suggestions
HTTP Code Mapping¶
Status strings automatically map to HTTP codes:
| Status Pattern | HTTP Code | GraphQL Code | Use Case |
|---|---|---|---|
success |
200 | 200 | Success |
created |
201 | 201 | Resource created |
updated |
200 | 200 | Resource updated |
deleted |
200 | 200 | Resource deleted |
failed:* |
200 | 422 | Validation/general errors |
not_found:* |
200 | 404 | Resource not found |
conflict:* |
200 | 409 | Business conflicts |
unauthorized:* |
200 | 401 | Authentication required |
forbidden:* |
200 | 403 | Permission denied |
timeout:* |
200 | 408 | Operation timeout |
noop:* |
200 | 422 | No changes made |
Note: GraphQL always returns HTTP 200. The code field provides application-level semantics.
Frontend Integration Examples¶
TypeScript Error Handling¶
interface GraphQLError {
code: number;
identifier: string;
message: string;
details?: any;
}
interface MutationResponse {
__typename: string;
message: string;
code: number;
status: string;
errors: GraphQLError[];
}
// Handle mutation response
function handleMutationResponse<T>(response: MutationResponse & T) {
if (response.__typename.endsWith('Error')) {
// Handle errors
for (const error of response.errors) {
switch (error.identifier) {
case 'validation':
showValidationError(error);
break;
case 'not_found':
showNotFoundError(error);
break;
case 'conflict':
showConflictError(error);
break;
default:
showGenericError(error);
}
}
} else {
// Handle success
showSuccess(response.message);
}
}
React Hook Example¶
function useMutationWithErrors() {
const [mutate, { loading, error }] = useMutation(CREATE_USER);
const handleSubmit = async (input: CreateUserInput) => {
try {
const result = await mutate({ variables: { input } });
if (result.data?.createUser.__typename === 'CreateUserError') {
const errors = result.data.createUser.errors;
// Group by field for form validation
const fieldErrors: Record<string, string[]> = {};
for (const error of errors) {
if (error.details?.field) {
fieldErrors[error.details.field] = fieldErrors[error.details.field] || [];
fieldErrors[error.details.field].push(error.message);
}
}
setFormErrors(fieldErrors);
} else {
// Success
navigate('/users');
}
} catch (err) {
// GraphQL/network errors
console.error('Mutation failed:', err);
}
};
return { handleSubmit, loading };
}
Vue.js Composition API¶
import { ref, computed } from 'vue';
import { useMutation } from '@vue/apollo-composable';
export function useCreateUser() {
const formErrors = ref<Record<string, string[]>>({});
const { mutate, loading, error } = useMutation(CREATE_USER, {
errorPolicy: 'all'
});
const submitForm = async (input: CreateUserInput) => {
formErrors.value = {};
const result = await mutate({ input });
if (result?.data?.createUser.__typename === 'CreateUserError') {
// Group errors by field
const errors = result.data.createUser.errors;
for (const error of errors) {
const field = error.details?.field;
if (field) {
formErrors.value[field] = formErrors.value[field] || [];
formErrors.value[field].push(error.message);
}
}
}
};
const hasErrors = computed(() => Object.keys(formErrors.value).length > 0);
return {
submitForm,
formErrors: readonly(formErrors),
hasErrors,
loading
};
}
Common Patterns¶
Validation Errors¶
Use case: Form validation with multiple field errors.
-- PostgreSQL
IF name_is_invalid THEN
errors := errors || jsonb_build_object(
'code', 422,
'identifier', 'name_invalid',
'message', 'Name must be 2-50 characters',
'details', jsonb_build_object(
'field', 'name',
'min_length', 2,
'max_length', 50,
'provided_length', length(name)
)
);
END IF;
Authorization Errors¶
Use case: Permission checks.
-- PostgreSQL
IF NOT user_has_permission(user_id, 'create_post') THEN
RETURN mutation_error('forbidden:insufficient_permissions',
'You do not have permission to create posts')
WITH metadata = jsonb_build_object(
'required_permission', 'create_post',
'user_id', user_id
);
END IF;
Business Rule Violations¶
Use case: Domain logic constraints.
-- PostgreSQL
IF user_post_count_today(user_id) >= 10 THEN
RETURN mutation_error('failed:daily_limit_exceeded',
'Daily post limit exceeded')
WITH metadata = jsonb_build_object(
'limit', 10,
'current_count', user_post_count_today(user_id),
'reset_time', 'midnight UTC'
);
END IF;
Not Found Errors¶
Use case: Resource lookup failures.
-- PostgreSQL
SELECT * INTO target_user FROM users WHERE id = user_id::uuid;
IF NOT FOUND THEN
RETURN mutation_not_found('User not found');
END IF;
Concurrency Conflicts¶
Use case: Optimistic locking failures.
-- PostgreSQL
UPDATE posts SET content = new_content, version = version + 1
WHERE id = post_id::uuid AND version = expected_version;
IF NOT FOUND THEN
RETURN mutation_error('conflict:concurrent_modification',
'Post was modified by another user')
WITH metadata = jsonb_build_object(
'conflict_type', 'concurrent_modification',
'entity_type', 'Post',
'entity_id', post_id
);
END IF;
Migration from Legacy Patterns¶
From field_errors Dictionaries¶
Before:
@fraiseql.failure
class CreateUserError(MutationResultBase):
field_errors: dict[str, str] = None
After:
@fraiseql.failure
class CreateUserError:
message: str
# errors array auto-populated
Migration logic:
-- Before: Set field_errors in Python resolver
-- After: Use metadata.errors in PostgreSQL function
result.metadata := jsonb_build_object('errors', jsonb_build_array(
jsonb_build_object(
'code', 422,
'identifier', 'email_required',
'message', 'Email is required',
'details', jsonb_build_object('field', 'email')
)
));
From ValidationError Lists¶
Before:
@fraiseql.failure
class ValidationError:
validation_errors: list[dict[str, str]] = None
After:
@fraiseql.failure
class ValidationError:
message: str
# errors auto-populated from metadata.errors
From MutationResultBase Dependency¶
Before:
@fraiseql.failure
class MyError(MutationResultBase):
custom_field: str
After:
@fraiseql.failure
class MyError:
message: str
custom_field: str
# errors still auto-populated
Best Practices¶
Error Identifier Naming¶
- Use
snake_casefor identifiers - Be specific but not verbose:
email_required, notemail_field_is_required - Group related errors:
validation_*,permission_*,conflict_* - Include context when helpful:
post_not_found,user_not_found
Error Message Guidelines¶
- User-friendly but informative
- Actionable when possible
- Consistent tone and style
- Include relevant values:
"Name must be 2-50 characters (got 100)"
Details Structure¶
- Use consistent field names:
field,constraint,value,expected - Include debugging info for developers
- Keep it structured for frontend consumption
- Avoid sensitive data in details
Testing Error Scenarios¶
def test_validation_errors():
# Test with invalid input
result = execute_mutation(create_user, {name: "", email: "invalid"})
assert result.errors[0].identifier == "validation"
assert len(result.errors) > 1 # Multiple validation errors
assert result.errors[0].details.field == "name"
Logging and Monitoring¶
-- Log errors for monitoring
CREATE OR REPLACE FUNCTION log_and_return_mutation(
result mutation_response,
log_message text
) RETURNS mutation_response AS $$
BEGIN
-- Log to your monitoring system
PERFORM log_error(log_message, result);
RETURN result;
END;
$$ LANGUAGE plpgsql;
Performance Considerations¶
Error Generation Overhead¶
- Auto-generated errors: Minimal (string parsing only)
- Explicit errors: JSON construction cost
- Only occurs on error paths (not success)
Database Function Design¶
- Keep validation logic close to data
- Use constraints for simple validations
- Reserve complex logic for business rules
- Consider performance impact of error aggregation
Frontend Error Handling¶
- Batch error processing when possible
- Cache error messages/translations
- Use error boundaries for graceful degradation
- Provide fallbacks for unknown error types
Troubleshooting¶
Errors Not Appearing¶
Check:
- Status string format: Must be
prefix:identifier - Function returns
mutation_responsetype - No explicit errors in
metadata.errors(would override)
Wrong Error Codes¶
Check:
- Status prefix mapping (see table above)
- Explicit error codes in
metadata.errors
Details Not Showing¶
Check:
detailsfield is valid JSONB- Frontend expects the structure you provide
- No null/undefined values breaking serialization
Performance Issues¶
Check:
- Error generation only on failure paths
- JSONB construction not in hot paths
- Logging not enabled for all errors