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 SETOF v_post
LANGUAGE plpgsql AS $$
DECLARE
v_post tb_post;
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;
RETURN QUERY SELECT * FROM v_post WHERE id = p_post_id;
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
requests_per_minute = 1000
burst = 50
per_user = true

When a client exceeds the limit, FraiseQL automatically returns:

{
"errors": [{
"message": "Too many requests",
"extensions": { "code": "RATE_LIMITED", "statusCode": 429, "retryAfter": 60 }
}]
}
[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 @authenticated and @requires_scope decorators:

Raised when a query or mutation decorated with @authenticated is called without a valid token:

{
"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

  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