GraphQL
Error Handling in GraphQL
Understanding GraphQL Error Handling
GraphQL has a standardized error format that allows partial data delivery alongside errors. Unlike REST APIs that return HTTP status codes, GraphQL responses always return 200 OK and include errors in the response body.
GraphQL Error Format
The standard GraphQL error structure includes:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"locations": [{"line": 2, "column": 3}],
"path": ["user"],
"extensions": {
"code": "USER_NOT_FOUND",
"timestamp": "2026-02-16T10:30:00Z"
}
}
]
}
Note: The
data field can contain partial results even when errors occur, allowing clients to use whatever data was successfully resolved.
Custom Error Classes
Create custom error classes for better error handling:
// errors/CustomErrors.js
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.extensions = {
code: 'UNAUTHENTICATED',
http: { status: 401 }
};
}
}
class ForbiddenError extends Error {
constructor(message) {
super(message);
this.extensions = {
code: 'FORBIDDEN',
http: { status: 403 }
};
}
}
class ValidationError extends Error {
constructor(message, fields) {
super(message);
this.extensions = {
code: 'BAD_USER_INPUT',
fields: fields,
http: { status: 400 }
};
}
}
class NotFoundError extends Error {
constructor(message) {
super(message);
this.extensions = {
code: 'NOT_FOUND',
http: { status: 404 }
};
}
}
module.exports = {
AuthenticationError,
ForbiddenError,
ValidationError,
NotFoundError
};
Using Custom Errors in Resolvers
const { AuthenticationError, NotFoundError } = require('./errors/CustomErrors');
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) {
throw new AuthenticationError('You must be logged in');
}
const user = await User.findById(id);
if (!user) {
throw new NotFoundError(`User with ID ${id} not found`);
}
return user;
}
},
Mutation: {
updateUser: async (_, { id, input }, context) => {
if (!context.user) {
throw new AuthenticationError('Authentication required');
}
if (context.user.id !== id && !context.user.isAdmin) {
throw new ForbiddenError('You can only update your own profile');
}
return await User.findByIdAndUpdate(id, input, { new: true });
}
}
};
Error Masking and Security
Mask internal errors in production to prevent information leakage:
const { ApolloServer } = require('apollo-server');
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// Log the original error
console.error(error);
// Don't expose internal errors to clients in production
if (process.env.NODE_ENV === 'production') {
// Check if it's a known error type
if (error.extensions?.code) {
return error;
}
// Mask unexpected errors
return {
message: 'Internal server error',
extensions: {
code: 'INTERNAL_SERVER_ERROR'
}
};
}
// In development, return full error
return error;
}
});
Warning: Never expose database errors, stack traces, or internal implementation details to clients in production. Always mask unexpected errors.
Partial Data with Errors
GraphQL allows returning partial data when some fields fail:
const resolvers = {
Query: {
dashboard: async (_, __, context) => {
if (!context.user) {
throw new AuthenticationError('Login required');
}
return {}; // Return empty object, fields resolve independently
}
},
Dashboard: {
profile: async (parent, _, context) => {
try {
return await User.findById(context.user.id);
} catch (error) {
console.error('Profile fetch failed:', error);
return null; // Partial failure - other fields still resolve
}
},
posts: async (parent, _, context) => {
try {
return await Post.find({ userId: context.user.id });
} catch (error) {
console.error('Posts fetch failed:', error);
throw new Error('Failed to load posts');
}
},
stats: async (parent, _, context) => {
return await Stats.getForUser(context.user.id);
}
}
};
// Query result with partial data:
// {
// "data": {
// "dashboard": {
// "profile": null,
// "posts": null,
// "stats": { "views": 1250, "followers": 42 }
// }
// },
// "errors": [
// {
// "message": "Failed to load posts",
// "path": ["dashboard", "posts"]
// }
// ]
// }
Field-Level Error Handling
const resolvers = {
User: {
email: (parent, _, context) => {
// Only show email to the user themselves or admins
if (context.user?.id === parent.id || context.user?.isAdmin) {
return parent.email;
}
throw new ForbiddenError('Email is private');
},
privateData: async (parent, _, context) => {
if (context.user?.id !== parent.id) {
throw new ForbiddenError('Cannot access private data');
}
try {
return await fetchPrivateData(parent.id);
} catch (error) {
console.error('Private data fetch failed:', error);
throw new Error('Failed to load private data');
}
}
}
};
Tip: Use error codes in the
extensions field to allow clients to handle different error types programmatically without parsing error messages.
Apollo Server Error Formatting
const { ApolloServer } = require('apollo-server');
const { v4: uuidv4 } = require('uuid');
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (error) => {
// Generate error ID for tracking
const errorId = uuidv4();
// Enhanced logging
console.error('GraphQL Error:', {
errorId,
message: error.message,
code: error.extensions?.code,
path: error.path,
locations: error.locations,
originalError: error.originalError
});
// Add error ID to response
return {
...error,
extensions: {
...error.extensions,
errorId,
timestamp: new Date().toISOString()
}
};
},
context: async ({ req }) => {
// Context can throw errors too
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
try {
const user = await verifyToken(token);
return { user };
} catch (error) {
throw new AuthenticationError('Invalid token');
}
}
return {};
}
});
Exercise:
- Create custom error classes for
RateLimitErrorandConflictError - Implement a resolver that throws different error types based on business logic
- Add error logging with error IDs that can be referenced by clients
- Create a
formatErrorfunction that masks stack traces in production - Test partial data delivery by making one field throw an error while others succeed