Gestionando Errores en GraphQL: Estrategias Robustas para APIs Fiables
Este tutorial aborda las mejores prácticas para el manejo de errores en GraphQL, desde la estructura de la respuesta de error estándar hasta la implementación de errores personalizados y su gestión en el cliente. Aprenderás a crear APIs GraphQL más robustas y a proporcionar una experiencia de usuario clara frente a los problemas.
📖 Introducción al Manejo de Errores en GraphQL
El manejo de errores es una parte crítica de cualquier API robusta y una API GraphQL no es una excepción. Una estrategia de manejo de errores bien definida no solo ayuda a los desarrolladores a depurar problemas más rápido, sino que también mejora la experiencia del usuario final al proporcionar mensajes claros y procesables. A diferencia de las APIs REST tradicionales, donde los códigos de estado HTTP se usan ampliamente para indicar el éxito o el fracaso, GraphQL tiene su propio enfoque para comunicar errores dentro de la respuesta de datos.
En este tutorial, exploraremos en profundidad cómo GraphQL maneja los errores, cómo podemos extender este comportamiento estándar para adaptarlo a nuestras necesidades y cómo los clientes pueden consumir y reaccionar a estos errores de manera efectiva.
📌 El Modelo de Errores Estándar de GraphQL
GraphQL está diseñado para ser flexible y, por defecto, los errores no interrumpen el flujo de la respuesta de datos. En lugar de ello, GraphQL incluye una clave errors en la respuesta JSON, junto con la clave data. Esto significa que una operación puede devolver algunos datos parciales y algunos errores al mismo tiempo.
La especificación de GraphQL define una estructura estándar para cada objeto de error dentro del array errors:
{
"errors": [
{
"message": "Descripción del error",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": ["hero", "name"],
"extensions": {
"code": "BAD_USER_INPUT",
"httpStatusCode": 400,
"exception": { /* detalles internos del servidor */ }
}
}
],
"data": {
"somePartialData": null
}
}
Donde:
message: Una cadena legible por humanos que describe el error.locations: Una lista de objetos que indican la línea y columna en el documento de origen de la consulta donde ocurrió el error. Esto es útil para depurar.path: Una lista de cadenas o enteros que describen la ruta del error dentro del árbol de resultados de la operación. Por ejemplo,["hero", "name"]indicaría que el error ocurrió al resolver el camponamedentro dehero.extensions(Opcional): Un mapa arbitrario de datos adicionales que el servidor puede incluir. Aquí es donde podemos añadir información personalizada, como códigos de error específicos de la aplicación o detalles de validación.
✨ Estrategias de Manejo de Errores en el Servidor
El manejo de errores en el servidor es crucial para proporcionar respuestas claras y útiles a los clientes. Aquí exploramos cómo podemos implementar esto en la práctica.
🛠️ Captura y Formateo de Errores en Resolvers
Dentro de tus resolvers, cualquier excepción no capturada se convertirá automáticamente en un error GraphQL estándar. Sin embargo, para proporcionar mensajes de error más significativos y estructurados, es una buena práctica capturar excepciones explícitamente y lanzar errores con un formato consistente.
Considera el siguiente ejemplo con un resolver de un usuario:
// Esquema GraphQL (ejemplo)
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String
email: String
}
// Resolver de ejemplo (Node.js con Apollo Server)
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.usersAPI.getUserById(id);
if (!user) {
// Lanzar un error específico si el usuario no se encuentra
throw new Error('Usuario no encontrado.');
}
return user;
} catch (error) {
// Capturar errores y re-lanzar con un formato más útil o registrar
console.error(`Error al obtener usuario ${id}:`, error.message);
throw new Error('No se pudo recuperar el usuario. Inténtelo de nuevo más tarde.');
}
},
},
};
En este ejemplo, estamos capturando errores internos y transformándolos en un mensaje más amigable para el cliente. Pero podemos ir un paso más allá.
🚀 Errores Personalizados con extensions
El campo extensions es tu mejor amigo para añadir contexto específico de la aplicación a los errores. Esto permite a los clientes tomar decisiones programáticas basadas en el tipo de error, en lugar de solo analizar el mensaje de texto.
Podemos crear clases de error personalizadas que se ajusten a la estructura de GraphQLError y permitan añadir extensions.
// helpers/errors.js
const { GraphQLError } = require('graphql');
class CustomError extends GraphQLError {
constructor(message, code, properties = {}) {
super(message, null, null, null, null, null, {
code,
...properties,
});
// Esto asegura que el nombre de la clase aparezca en la pila de errores
Object.defineProperty(this, 'name', { value: 'CustomError' });
}
}
class AuthenticationError extends CustomError {
constructor(message = 'No autenticado') {
super(message, 'UNAUTHENTICATED');
Object.defineProperty(this, 'name', { value: 'AuthenticationError' });
}
}
class AuthorizationError extends CustomError {
constructor(message = 'No autorizado') {
super(message, 'FORBIDDEN');
Object.defineProperty(this, 'name', { value: 'AuthorizationError' });
}
}
class UserNotFoundError extends CustomError {
constructor(message = 'Usuario no encontrado', userId) {
super(message, 'NOT_FOUND', { entity: 'User', id: userId });
Object.defineProperty(this, 'name', { value: 'UserNotFoundError' });
}
}
module.exports = {
AuthenticationError,
AuthorizationError,
UserNotFoundError,
CustomError
};
Ahora, podemos usar estos errores personalizados en nuestros resolvers:
// Resolver actualizado
const { AuthenticationError, UserNotFoundError } = require('./helpers/errors');
const resolvers = {
Query: {
user: async (_, { id }, { dataSources, userContext }) => {
if (!userContext || !userContext.isAuthenticated) {
throw new AuthenticationError('Debes iniciar sesión para ver usuarios.');
}
const user = await dataSources.usersAPI.getUserById(id);
if (!user) {
throw new UserNotFoundError(null, id); // 'null' para usar el mensaje por defecto
}
return user;
},
},
};
La respuesta para un UserNotFoundError ahora incluiría:
{
"errors": [
{
"message": "Usuario no encontrado",
"locations": [ { "line": ..., "column": ... } ],
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"entity": "User",
"id": "123"
}
}
],
"data": {
"user": null
}
}
Este enfoque proporciona una forma mucho más estructurada y programática de manejar errores en el cliente.
📊 Formato de Errores Globales
La mayoría de los servidores GraphQL (como Apollo Server, GraphQL Yoga) permiten personalizar cómo se formatean los errores antes de enviarlos al cliente. Esto es útil para censurar información sensible en producción o para añadir campos personalizados a todos los errores.
Por ejemplo, con Apollo Server, puedes usar la opción formatError:
const { ApolloServer } = require('apollo-server');
const { GraphQLError } = require('graphql');
const server = new ApolloServer({
// ... otros configs
formatError: (error) => {
// Si es un error personalizado, podemos mantener sus extensiones
if (error.extensions && error.extensions.code) {
return error;
}
// Para otros errores no controlados, censura la pila de errores en producción
const isProduction = process.env.NODE_ENV === 'production';
return isProduction
? new GraphQLError('Error interno del servidor. Inténtelo de nuevo más tarde.')
: error; // En desarrollo, muestra el error completo
},
});
🔄 Manejo de Errores en el Cliente
Una vez que el servidor envía los errores, el cliente necesita saber cómo procesarlos y reaccionar a ellos. Aquí hay algunas estrategias comunes.
🔍 Identificación de Errores
El primer paso es identificar si la respuesta contiene errores. Las librerías cliente de GraphQL (como Apollo Client, Relay) normalmente exponen la clave errors en el objeto de respuesta.
// Ejemplo con Apollo Client en React
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
function UserProfile({ userId }) {
const { loading, error, data } = useQuery(GET_USER, { variables: { id: userId } });
if (loading) return <p>Cargando perfil...</p>;
if (error) {
// El objeto 'error' de Apollo Client contiene la clave 'graphQLErrors'
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
return (
<div className="error-message">
<h3>Errores de GraphQL:</h3>
<ul>
{error.graphQLErrors.map((err, index) => (
<li key={index}>
<strong>Código:</strong> {err.extensions?.code || 'N/A'}
<br />
<strong>Mensaje:</strong> {err.message}
{err.extensions?.entity && <p>Entidad: {err.extensions.entity}, ID: {err.extensions.id}</p>}
</li>
))}
</ul>
</div>
);
}
// Manejar errores de red o cualquier otro tipo de error
return <p>Error general: {error.message}</p>;
}
return (
<div>
<h2>Perfil de {data.user.name}</h2>
<p>Email: {data.user.email}</p>
</div>
);
}
🎯 Reacción a Errores Personalizados
Usando los códigos de error en extensions, el cliente puede reaccionar de manera inteligente a diferentes situaciones:
UNAUTHENTICATED/FORBIDDEN: Redirigir al usuario a la página de inicio de sesión o mostrar un mensaje de "acceso denegado".NOT_FOUND: Mostrar un mensaje de "recurso no encontrado" y tal vez un botón para volver a la página anterior.BAD_USER_INPUT: Mostrar errores de validación directamente en el formulario donde ocurrió el problema.
Aquí tienes un ejemplo de cómo podrías manejar esto con un switch statement o un mapeo de funciones:
function handleGraphQLError(error) {
const code = error.extensions?.code;
switch (code) {
case 'UNAUTHENTICATED':
alert('Tu sesión ha expirado. Por favor, inicia sesión de nuevo.');
window.location.href = '/login';
break;
case 'FORBIDDEN':
alert('No tienes permiso para realizar esta acción.');
break;
case 'NOT_FOUND':
alert(`El recurso '${error.extensions.entity}' con ID '${error.extensions.id}' no fue encontrado.`);
break;
case 'BAD_USER_INPUT':
// Aquí podrías actualizar el estado de un formulario para mostrar el error al usuario
console.log('Errores de validación:', error.extensions.validationErrors);
alert('Por favor, revisa los datos introducidos.');
break;
default:
console.error('Error desconocido:', error.message);
alert('Ha ocurrido un error inesperado.');
}
}
// Uso dentro del componente React
// ... (en el bloque if (error))
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
error.graphQLErrors.forEach(handleGraphQLError);
return <p>Ocurrieron errores. Revisa la consola para más detalles.</p>;
}
// ...
📝 Mostrar Mensajes Amigables
Incluso con errores programáticos, el mensaje final que ve el usuario debe ser claro y útil. Evita mostrar mensajes técnicos y opta por explicaciones simples de lo que salió mal y, si es posible, cómo solucionarlo.
🗺️ Diagrama de Flujo del Manejo de Errores
Para resumir el proceso, aquí tienes un diagrama de flujo simple de cómo los errores viajan desde el resolver hasta el cliente:
🧪 Pruebas de Errores
Es fundamental probar el manejo de errores en tu API GraphQL para asegurarte de que se comporta como esperas en diferentes escenarios:
- Validación de entradas: ¿Los errores de validación se devuelven correctamente con los códigos apropiados?
- Autenticación/Autorización: ¿Se bloquea el acceso a recursos no autorizados y se informa al cliente con el error correcto (
UNAUTHENTICATED,FORBIDDEN)? - Recursos no encontrados: ¿Se informa de la ausencia de recursos con
NOT_FOUND? - Errores internos del servidor: ¿El
formatErrorglobal censura la información sensible en producción y proporciona un mensaje genérico? - Errores parciales: ¿Las consultas que fallan en un subcampo devuelven datos parciales y el error correspondiente sin bloquear toda la consulta?
Ejemplo de Test (Jest/Supertest)
const request = require('supertest');
const { createTestClient } = require('apollo-server-testing');
const { ApolloServer, gql } = require('apollo-server');
const { AuthenticationError, UserNotFoundError } = require('./helpers/errors');
// Definimos un esquema y resolvers de prueba
const typeDefs = gql`
type User {
id: ID!
name: String
}
type Query {
me: User
user(id: ID!): User
}
`;
const resolvers = {
Query: {
me: (_, __, { isAuthenticated }) => {
if (!isAuthenticated) {
throw new AuthenticationError();
}
return { id: '1', name: 'Test User' };
},
user: (_, { id }) => {
if (id === '999') {
throw new UserNotFoundError(null, id);
}
return { id: id, name: `User ${id}` };
},
},
};
describe('Error Handling', () => {
let server, query;
beforeAll(() => {
server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({ isAuthenticated: req.headers.authorization === 'Bearer valid_token' }),
formatError: (error) => {
// En test, no censuramos, pero podemos validar la estructura
return error;
},
});
({ query } = createTestClient(server));
});
test('should return AuthenticationError for unauthenticated user', async () => {
const ME_QUERY = gql`
query {
me {
id
name
}
}
`;
const res = await query({ query: ME_QUERY });
expect(res.data.me).toBeNull();
expect(res.errors).toBeDefined();
expect(res.errors[0].extensions.code).toBe('UNAUTHENTICATED');
expect(res.errors[0].message).toBe('No autenticado');
});
test('should return UserNotFoundError for non-existent user', async () => {
const USER_QUERY = gql`
query ($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const res = await query({ query: USER_QUERY, variables: { id: '999' } });
expect(res.data.user).toBeNull();
expect(res.errors).toBeDefined();
expect(res.errors[0].extensions.code).toBe('NOT_FOUND');
expect(res.errors[0].extensions.entity).toBe('User');
expect(res.errors[0].extensions.id).toBe('999');
expect(res.errors[0].message).toBe('Usuario no encontrado');
});
test('should return user for authenticated and existing user', async () => {
const ME_QUERY = gql`
query {
me {
id
name
}
}
`;
// Simula una petición autenticada
const authServer = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ isAuthenticated: true }),
});
const { query: authQuery } = createTestClient(authServer);
const res = await authQuery({ query: ME_QUERY });
expect(res.errors).toBeUndefined();
expect(res.data.me).toEqual({ id: '1', name: 'Test User' });
});
});
⚖️ Comparación con el Manejo de Errores REST
Es útil entender las diferencias entre GraphQL y REST en cuanto al manejo de errores.
| Característica | API REST | API GraphQL |
|---|---|---|
| --- | --- | --- |
| Códigos de Estado HTTP | Fundamental para errores (4xx, 5xx) | Siempre 200 OK (para respuestas válidas), errores en el body. |
| Estructura de la Respuesta | Varía ampliamente, a menudo JSON o XML. | JSON estándar con data y errors |
| --- | --- | --- |
| Errores Parciales | Difícil de lograr, requiere endpoints específicos. | Soportado por diseño; data puede tener campos nulos. |
| Flexibilidad del Error | Códigos de estado fijos. | Personalizable con extensions para detalles específicos de la app. |
| --- | --- | --- |
| Depuración Cliente | Basado en códigos de estado y mensajes. | locations, path, y extensions facilitan la depuración. |
Conclusión
El manejo de errores en GraphQL es una capacidad potente y flexible que, cuando se implementa correctamente, puede mejorar significativamente la robustez y la usabilidad de tus APIs. Al utilizar la estructura estándar de errores de GraphQL y complementarla con errores personalizados a través de extensions, puedes proporcionar a los clientes una información rica y programática para reaccionar a los fallos de manera elegante.
Recuerda la importancia de la seguridad y de no exponer detalles internos del servidor en producción, usando la función formatError de tu servidor GraphQL como una capa de protección final. Con estas estrategias, tus APIs GraphQL serán más fiables y fáciles de consumir para cualquier aplicación cliente.
Tutoriales relacionados
- Explorando GraphQL: Un Viaje Práctico para Construir APIs Flexibles y Eficientesintermediate25 min
- Explorando la Introspección GraphQL: Descubriendo Esquemas y Metadatos de tus APIsintermediate18 min
- Optimización de GraphQL con Dataloader: Estrategias para Evitar el Problema N+1intermediate15 min
- Uniones y Fragmentos GraphQL: Dominando la Composición de Datos Avanzadaintermediate18 min
- Diseñando APIs GraphQL Robustas con Directivas Personalizadas: Más Allá de lo Básicointermediate20 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!