Skip to content

Error Handling

FraiseQL propagates errors from three sources — PostgreSQL functions, middleware, and the GraphQL layer — in a consistent JSON structure that clients can inspect programmatically.

Every error response follows the GraphQL specification:

{
"errors": [
{
"message": "Post not found",
"extensions": {
"code": "NOT_FOUND",
"statusCode": 404,
"requestId": "req_abc123"
}
}
],
"data": null
}
FieldTypeDescription
messagestringHuman-readable description
extensions.codestringMachine-readable error code
extensions.statusCodenumberCorresponding HTTP status code
extensions.requestIdstringUse when reporting issues to support

Most business logic in FraiseQL lives in PostgreSQL functions. Raise errors with RAISE EXCEPTION:

db/schema/03_functions/fn_publish_post.sql
CREATE OR REPLACE FUNCTION fn_publish_post(p_post_id UUID, p_user_id UUID)
RETURNS mutation_response
LANGUAGE plpgsql AS $$
DECLARE
v_post tb_post;
v_entity JSONB;
BEGIN
SELECT * INTO v_post
FROM tb_post
WHERE id = p_post_id;
-- NOT_FOUND
IF NOT FOUND THEN
RAISE EXCEPTION 'Post not found'
USING ERRCODE = 'P0001',
DETAIL = 'post_id: ' || p_post_id::text,
HINT = 'NOT_FOUND';
END IF;
-- FORBIDDEN
IF v_post.fk_user != (SELECT pk_user FROM tb_user WHERE id = p_user_id) THEN
RAISE EXCEPTION 'Cannot publish another user''s post'
USING ERRCODE = 'P0002',
HINT = 'FORBIDDEN';
END IF;
-- INVALID_STATE
IF v_post.is_published THEN
RAISE EXCEPTION 'Post is already published'
USING ERRCODE = 'P0003',
HINT = 'INVALID_STATE';
END IF;
UPDATE tb_post SET is_published = true, updated_at = now()
WHERE pk_post = v_post.pk_post;
SELECT data INTO v_entity FROM v_post WHERE id = p_post_id;
RETURN ROW('success', 'Post published', p_post_id, 'Post',
v_entity, NULL, NULL, NULL)::mutation_response;
END;
$$;

FraiseQL maps the PostgreSQL error to a GraphQL response:

{
"errors": [{
"message": "Post not found",
"extensions": {
"code": "NOT_FOUND",
"statusCode": 404
}
}]
}

Use consistent error codes across all PostgreSQL functions:

CodeHTTP StatusWhen to Use
NOT_FOUND404Resource does not exist
FORBIDDEN403Caller lacks permission
CONFLICT409Unique constraint violation, duplicate resource
INVALID_STATE422Operation not valid in current state
INVALID_INPUT422Input fails business validation
RATE_LIMITED429Request rate exceeded

Service-wide error conditions — rate limits, authentication requirements — are enforced by the FraiseQL runtime, not by Python schema code. Configure them in fraiseql.toml:

[security.rate_limiting]
enabled = true
auth_start_max_requests = 100
auth_start_window_secs = 60
failed_login_max_requests = 5
failed_login_window_secs = 3600

When a client exceeds the limit, FraiseQL automatically returns:

{
"errors": [{
"message": "Too many requests",
"extensions": { "code": "RATE_LIMITED", "statusCode": 429, "retryAfter": 60 }
}]
}
[fraiseql.security]
default_policy = "authenticated" # Require a valid JWT on all operations

Use your reverse proxy or load balancer to return 503 during maintenance windows. FraiseQL’s /health/ready endpoint can serve as the upstream health check target — set it to return 503 to drain traffic before taking the server down.


FraiseQL’s compile-time validators raise structured errors automatically when input fails validation:

{
"errors": [{
"message": "Invalid input for field 'email'",
"extensions": {
"code": "INVALID_INPUT",
"statusCode": 422,
"field": "email",
"reason": "Must be a valid email address"
}
}]
}

For business-logic validation that can’t happen at compile time, raise from the PostgreSQL function (see above).


FraiseQL raises these automatically based on JWT authentication and field-level scope enforcement (configured via fraiseql.field(requires_scope=...)):

Raised when a query or mutation requires authentication and no valid token is present:

{
"errors": [{
"message": "Unauthorized",
"extensions": {
"code": "UNAUTHORIZED",
"statusCode": 401,
"reason": "Token expired"
}
}]
}

Raised when the token is valid but lacks a required scope:

{
"errors": [{
"message": "Insufficient permissions",
"extensions": {
"code": "FORBIDDEN",
"statusCode": 403,
"requiredScopes": ["write:posts"],
"grantedScopes": ["read:posts"]
}
}]
}

Clients consuming your FraiseQL API receive standard GraphQL error responses. Here are handling patterns for common client languages:

import httpx
async def publish_post(post_id: str, token: str) -> dict:
async with httpx.AsyncClient() as http:
response = await http.post(
"https://api.example.com/graphql",
headers={"Authorization": f"Bearer {token}"},
json={
"query": """
mutation PublishPost($id: ID!) {
publishPost(id: $id) { id isPublished }
}
""",
"variables": {"id": post_id},
},
)
result = response.json()
if "errors" in result:
error = result["errors"][0]
code = error.get("extensions", {}).get("code")
match code:
case "NOT_FOUND":
raise ValueError(f"Post {post_id} not found")
case "FORBIDDEN":
raise PermissionError("Not your post")
case "UNAUTHORIZED":
raise PermissionError("Please log in")
case _:
raise RuntimeError(error["message"])
return result["data"]["publishPost"]

For transient errors (RATE_LIMITED, SERVICE_UNAVAILABLE):

import asyncio
import random
async def with_retry(operation, max_attempts=3):
for attempt in range(max_attempts):
try:
return await operation()
except Exception as e:
code = getattr(e, 'code', None)
if code not in ('RATE_LIMITED', 'SERVICE_UNAVAILABLE'):
raise
if attempt == max_attempts - 1:
raise
# Exponential backoff with jitter
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(min(wait, 60))
# Usage
result = await with_retry(lambda: publish_post(post_id, token))

CodeStatusDescription
UNAUTHORIZED401Token missing or expired
INVALID_TOKEN401Token signature invalid
FORBIDDEN403Insufficient permission or scope
CodeStatusDescription
INVALID_INPUT422Input validation failed
MISSING_REQUIRED_FIELD422Required field absent
GRAPHQL_VALIDATION_FAILED422Query references unknown field or type
CodeStatusDescription
NOT_FOUND404Resource does not exist
CONFLICT409Duplicate or state conflict
INVALID_STATE422Operation invalid in current state
CodeStatusDescription
RATE_LIMITED429Request rate exceeded
SERVICE_UNAVAILABLE503Scheduled maintenance or overload
INTERNAL_ERROR500Unhandled server error — report with requestId

GraphQL, REST, and gRPC each have their own error representation:

GraphQL: Standard errors array in the response body per the GraphQL spec.

REST: HTTP status code + JSON error body:

{
"message": "User not found",
"code": "NOT_FOUND"
}

gRPC: gRPC status codes in the response trailer.

Cross-transport error mapping:

Error typeGraphQLREST statusgRPC status
Validation errorerrors array400INVALID_ARGUMENT
Authentication failureerrors array401UNAUTHENTICATED
Permission deniederrors array403PERMISSION_DENIED
Not founderrors array404NOT_FOUND
Rate limitederrors array429RESOURCE_EXHAUSTED
Server errorerrors array500INTERNAL

  1. Use consistent error codes — Define them in a shared SQL file and reference it in all functions
  2. Include enough context — The DETAIL clause in RAISE EXCEPTION gives clients actionable information
  3. Never expose internals — Catch unexpected errors and return INTERNAL_ERROR; log the details server-side
  4. Log with requestId — Include requestId in all error logs for traceability
  5. Validate at boundaries — Use compile-time validation for schema constraints; use PostgreSQL functions for business rules