tutoriales.com

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.

Intermedio15 min de lectura3 views
Reportar error

📖 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.

💡 Consejo: Un buen manejo de errores es una de las características que distinguen una API profesional de una que no lo es. Invierte tiempo en diseñarlo correctamente.

📌 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 campo name dentro de hero.
  • 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.
⚠️ Advertencia: Evita exponer detalles sensibles o trazas de pila completas en el campo `extensions` en entornos de producción. Utiliza códigos de error abstractos o mensajes genéricos.

✨ 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
  },
});
🔥 Importante: Asegúrate siempre de no exponer información sensible en los errores en un entorno de producción. Un buen `formatError` es tu última línea de defensa.

🔄 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>
  );
}
📌 Nota: Los errores de red (por ejemplo, el servidor no responde) a menudo se manejan de manera diferente por las librerías cliente y pueden no aparecer en `graphQLErrors`, sino como un error de red separado.

🎯 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.

💡 Consejo: Considera mantener un registro de tus códigos de error y sus mensajes asociados en la documentación de tu API para que los desarrolladores cliente puedan integrarlos fácilmente.

🗺️ 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:

Petición Cliente Servidor GraphQL Resolver Exitoso Datos al Cliente Error en Resolver Captura y Formato de Errores (custom/global) Respuesta JSON (data + errors) Cliente GraphQL (parsea 'errors') Muestra Mensaje Redirige/Acción

🧪 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 formatError global 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ísticaAPI RESTAPI GraphQL
---------
Códigos de Estado HTTPFundamental para errores (4xx, 5xx)Siempre 200 OK (para respuestas válidas), errores en el body.
Estructura de la RespuestaVaría ampliamente, a menudo JSON o XML.JSON estándar con data y errors
---------
Errores ParcialesDifícil de lograr, requiere endpoints específicos.Soportado por diseño; data puede tener campos nulos.
Flexibilidad del ErrorCódigos de estado fijos.Personalizable con extensions para detalles específicos de la app.
---------
Depuración ClienteBasado en códigos de estado y mensajes.locations, path, y extensions facilitan la depuración.
90% Completado

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

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!