Patrones de manejo de errores en APIs Go que estandarizan errores tipados, códigos HTTP, identificadores de petición y mensajes seguros sin filtrar detalles internos.

Cuando cada endpoint informa fallos de forma diferente, los clientes dejan de confiar en tu API. Una ruta devuelve { error: not found }, otra devuelve { message: missing }, y una tercera envía texto plano. Incluso si el significado es parecido, el código del cliente tiene que adivinar qué sucedió.
El coste aparece rápido. Los equipos crean lógica de parsing frágil y añaden casos especiales por endpoint. Los reintentos son arriesgados porque el cliente no puede distinguir “intentar de nuevo más tarde” de “tu entrada es incorrecta”. Aumentan los tickets de soporte porque el cliente solo ve un mensaje vago, y tu equipo no puede relacionarlo fácilmente con una línea de log del servidor.
Un escenario común: una app móvil llama a tres endpoints durante el registro. El primero devuelve HTTP 400 con un mapa de errores por campo, el segundo devuelve HTTP 500 con un string de stack trace, y el tercero devuelve HTTP 200 con { ok: false }. El equipo de la app implementa tres manejadores distintos para errores, y tu equipo backend sigue recibiendo reportes como “el registro a veces falla” sin pista clara de por dónde empezar.
El objetivo es un contrato predecible. Los clientes deberían poder leer de forma fiable qué pasó, si fue culpa suya o tuya, si tiene sentido reintentar y obtener un request ID que puedan pegar en soporte.
Nota de alcance: esto se centra en APIs HTTP JSON (no gRPC), pero las mismas ideas aplican dondequiera que devuelvas errores a otros sistemas.
Elige un contrato claro para errores y haz que cada endpoint lo cumpla. “Consistente” significa la misma forma JSON, el mismo significado de campos y el mismo comportamiento sin importar qué handler falle. Una vez hecho eso, los clientes dejan de adivinar y empiezan a manejar errores.
Un contrato útil ayuda a los clientes a decidir qué hacer a continuación. Para la mayoría de apps, cada respuesta de error debería responder tres preguntas:
Un conjunto práctico de reglas:
Decide desde el inicio qué nunca debe aparecer en las respuestas. Ítems comunes a evitar incluyen fragmentos SQL, traces de stack, hostnames internos, secretos y strings crudos de dependencias.
Mantén una separación clara: un mensaje corto para el usuario (seguro, cortés, accionable) y detalles internos (error completo, stack y contexto) guardados en logs. Por ejemplo, “No se pudieron guardar tus cambios. Por favor intenta nuevamente.” es seguro. “pq: duplicate key value violates unique constraint users_email_key” no lo es.
Cuando cada endpoint sigue el mismo contrato, los clientes pueden construir un único manejador de errores y reutilizarlo en todas partes.
Los clientes solo pueden manejar errores de forma limpia si cada endpoint responde con la misma forma. Elige un único sobre JSON y mantenlo estable.
Un valor práctico por defecto es un objeto error más un request_id en el nivel superior:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
El status HTTP da la categoría amplia (400, 401, 409, 500). El error.code legible por máquina da el caso específico sobre el que el cliente puede ramificarse. Esa separación importa porque muchos problemas distintos comparten el mismo status. Una app móvil puede mostrar UI diferente para EMAIL_TAKEN vs WEAK_PASSWORD, aunque ambos sean 400.
Mantén error.message seguro y humano. Debe ayudar al usuario a corregir el problema, pero nunca filtrar internals (SQL, traces, nombres de proveedores, rutas de archivos).
Los campos opcionales son útiles cuando se mantienen previsibles:
details.fields como un mapa de campo a mensaje.details.retry_after_seconds.details.docs_hint como texto plano (no una URL).Para compatibilidad hacia atrás, trata los valores de error.code como parte del contrato de tu API. Añade nuevos códigos sin cambiar significados antiguos. Solo añade campos opcionales y asume que los clientes ignorarán los campos que no reconozcan.
El manejo de errores se complica cuando cada handler inventa su propia manera de señalar fallos. Un pequeño conjunto de errores tipados lo arregla: los handlers devuelven tipos de error conocidos y una capa de respuesta los convierte en respuestas coherentes.
Un conjunto práctico inicial cubre la mayoría de endpoints:
La clave es estabilidad en el nivel superior, aunque cambie la causa raíz. Puedes envolver errores de bajo nivel (SQL, red, parseo JSON) y aun así devolver el mismo tipo público que el middleware puede detectar.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
En tu handler, devuelve NotFoundError{Resource: "user", ID: id, Err: err} en lugar de filtrar sql.ErrNoRows directamente.
Para comprobar errores, prefiere errors.As para tipos personalizados y errors.Is para errores centinela. Los errores centinela (como var ErrUnauthorized = errors.New("unauthorized")) funcionan para casos simples, pero los tipos personalizados ganan cuando necesitas contexto seguro (como qué recurso faltó) sin cambiar tu contrato público de respuesta.
Sé estricto sobre lo que adjuntas:
Err subyacente, info de stack, errores SQL crudos, tokens, datos de usuario.Esa separación te permite ayudar a clientes sin exponer internals.
Una vez que tienes errores tipados, el siguiente trabajo es aburrido pero esencial: el mismo tipo de error debe producir siempre el mismo status HTTP. Los clientes basarán su lógica en ello.
Un mapeo práctico que funciona para la mayoría de APIs:
| Error type (example) | Status | When to use it |
|---|---|---|
| BadRequest (malformed JSON, missing required query param) | 400 | The request is not valid at a basic protocol or format level. |
| Unauthenticated (no/invalid token) | 401 | The client needs to authenticate. |
| Forbidden (no permission) | 403 | Auth is valid, but access is not allowed. |
| NotFound (resource ID does not exist) | 404 | The requested resource is not there (or you choose to hide existence). |
| Conflict (unique constraint, version mismatch) | 409 | The request is well-formed, but it clashes with current state. |
| ValidationFailed (field rules) | 422 | The shape is fine, but business validation fails (email format, min length). |
| RateLimited | 429 | Too many requests in a time window. |
| Internal (unknown error) | 500 | Bug or unexpected failure. |
| Unavailable (dependency down, timeout, maintenance) | 503 | Temporary server-side issue. |
Dos distinciones que previenen mucha confusión:
La guía de reintentos importa:
Un request ID es un valor único corto que identifica una llamada API de extremo a extremo. Si los clientes lo ven en cada respuesta, el soporte se simplifica: “Envíame el request ID” suele ser suficiente para encontrar los logs exactos y el fallo concreto.
Este hábito rinde tanto en respuestas exitosas como en errores.
Usa una regla clara: si el cliente envía un request ID, consérvalo. Si no, créalo.
X-Request-Id).Pon el request ID en tres lugares:
request_id en tu esquema estándar)Para endpoints en batch o trabajos en background, mantén un parent request ID. Ejemplo: un cliente sube 200 filas, 12 fallan validación y encolas trabajo. Devuelve un request_id para toda la llamada y añade un parent_request_id en cada trabajo y en cada error por ítem. Así puedes trazar “una subida” aunque se disperse en muchas tareas.
Los clientes necesitan una respuesta de error clara y estable. Tus logs necesitan la verdad desordenada. Mantén esos dos mundos separados: devuelve un mensaje público seguro y un código de error público al cliente, mientras registras la causa interna, el stack y el contexto en el servidor.
Registra un evento estructurado por cada respuesta de error, buscable por request_id.
Campos que merece la pena mantener consistentes:
Almacena los detalles internos solo en logs del servidor (o en un almacén interno de errores). El cliente nunca debe ver errores de base de datos crudos, texto de consultas, traces de stack o mensajes de proveedores. Si ejecutas múltiples servicios, un campo interno como source (api, db, auth, upstream) puede acelerar el triage.
Vigila endpoints ruidosos y errores por límite de tasa. Si un endpoint puede generar el mismo 429 o 400 miles de veces por minuto, evita spam de logs: samplea eventos repetidos o baja la severidad para errores esperados mientras sigues contando en métricas.
Las métricas detectan problemas antes que los logs. Lleva contadores agrupados por status HTTP y código de error, y alerta por picos repentinos. Si RATE_LIMITED salta 10x tras un deploy, lo verás rápido incluso si los logs están sampleados.
La forma más fácil de hacer errores consistentes es dejar de manejarlos “en todas partes” y encauzarlos por una pequeña canalización. Esa canalización decide qué ve el cliente y qué se guarda en logs.
Comienza con un pequeño conjunto de códigos de error en los que los clientes puedan confiar (por ejemplo: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Envuélvelos en un error tipado que exponga solo campos públicos y seguros (code, mensaje seguro, detalles opcionales como qué campo está mal). Mantén las causas internas privadas.
Luego implementa una función traductora que convierta cualquier error en (statusCode, responseBody). Aquí es donde los errores tipados se mapean a status HTTP, y los errores desconocidos se convierten en un 500 seguro.
Después, añade middleware que:
request_idUn panic nunca debe volcar traces al cliente. Devuelve un 500 normal con un mensaje genérico y registra el panic completo con el mismo request_id.
Finalmente, cambia tus handlers para que retornen un error en lugar de escribir la respuesta directamente. Un wrapper puede llamar al handler, ejecutar el traductor y escribir JSON con la forma estándar.
Un checklist compacto:
Las pruebas golden importan porque fijan el contrato. Si alguien cambia luego un mensaje o un código de estado, las pruebas fallan antes de que los clientes se sorprendan.
Imagina un endpoint: una app cliente crea un registro de cliente.
POST /v1/customers con JSON como { email: [email protected], name: Pat }. El servidor siempre devuelve la misma forma de error e incluye siempre un request_id.
Falta el email o tiene formato inválido. El cliente puede resaltar el campo.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
El email ya existe. El cliente puede sugerir iniciar sesión o elegir otro correo.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Una dependencia está caída. El cliente puede reintentar con backoff y mostrar un mensaje tranquilo.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Con un contrato único, el cliente reacciona de forma consistente:
details.fieldsrequest_id como ID de soportePara soporte, ese mismo request_id es el camino más rápido hacia la causa real en los logs internos, sin exponer traces ni errores de base de datos.
La forma más rápida de molestar a los clientes de una API es hacerles adivinar. Si un endpoint devuelve { "error": "..." } y otro devuelve { "message": "..." }, cada cliente se convierte en una pila de casos especiales y los bugs se esconden durante semanas.
Algunos errores que aparecen una y otra vez:
code estable que los clientes puedan usar.request_id solo en fallos, de modo que no puedas correlacionar un reporte de usuario con la llamada exitosa que desencadenó un problema posterior.Filtrar internals es la trampa más fácil. Un handler devuelve err.Error() por conveniencia, y entonces un nombre de constraint o un mensaje de un tercero aparece en respuestas de producción. Mantén el mensaje al cliente seguro y corto, y pon la causa detallada en logs.
Confiar solo en texto es otro problema lento. Si el cliente tiene que parsear frases en inglés como “email already exists”, no puedes cambiar la redacción sin romper la lógica. Los códigos de error estables permiten ajustar mensajes, traducirlos y mantener el comportamiento.
Trata los códigos de error como parte de tu contrato público. Si debes cambiar uno, añade un nuevo código y mantiene el antiguo funcionando por un tiempo, incluso si ambos mapean al mismo status HTTP.
Finalmente, incluye el mismo campo request_id en cada respuesta, éxito o fallo. Cuando un usuario dice “funcionó y luego falló”, ese ID suele ahorrar una hora de conjeturas.
Antes del release, haz una pasada rápida para consistencia:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Añade pruebas para que los handlers no devuelvan códigos desconocidos por accidente.request_id y regístralo en cada request, incluidos panics y timeouts.Después, revisa manualmente algunos endpoints. Fuerza un error de validación, un registro faltante y una falla inesperada. Si las respuestas difieren entre endpoints (cambian campos, status o mensajes que filtran de más), arregla la canalización compartida antes de añadir más funciones.
Una regla práctica: si un mensaje ayudaría a un atacante o confundiría a un usuario normal, pertenece a los logs, no a la respuesta.
Escribe el contrato de errores que quieres que cada endpoint siga, incluso si tu API ya está en producción. Un contrato compartido (status, código de error estable, mensaje seguro y request_id) es la forma más rápida de hacer errores previsibles para los clientes.
Luego migra gradualmente. Conserva tus handlers existentes pero encamina sus fallos a través de un mapeador que convierta errores internos a tu forma pública. Esto mejora la consistencia sin un riesgo de refactor grande y evita que nuevos endpoints inventen formatos.
Mantén un pequeño catálogo de códigos de error y trátalo como parte de tu API. Cuando alguien quiera añadir un código nuevo, haz una revisión rápida: ¿es realmente nuevo?, ¿está nombrado claramente? y ¿mapea al status HTTP correcto?
Añade unas pocas pruebas que detecten deriva:
request_id.error.code está presente y proviene del catálogo.error.message sigue siendo seguro y nunca incluye detalles internos.Si estás construyendo un backend en Go desde cero, ayuda fijar el contrato pronto. Por ejemplo, Koder.ai (koder.ai) incluye un modo de planificación donde puedes definir convenciones como un esquema de error y un catálogo de códigos desde el inicio, y así mantener los handlers alineados conforme la API crece.
Usa una sola forma JSON para todas las respuestas de error, en todos los endpoints. Un valor práctico es un request_id a nivel superior más un objeto error con code, message y details opcionales para que los clientes puedan analizar y reaccionar con fiabilidad.
Devuelve error.message como una frase corta y segura para el usuario y guarda la causa real en los logs del servidor. No devuelvas errores crudos de base de datos, traces de stack, hostnames internos ni mensajes de dependencias, aunque sea útil en desarrollo.
Usa un error.code estable para la lógica de máquina y deja que el código HTTP describa la categoría amplia. Los clientes deben ramificarse según error.code (por ejemplo, ALREADY_EXISTS) y tratar el status como guía (por ejemplo, 409 indica conflicto de estado).
Usa 400 cuando la petición no se puede interpretar o parsear de forma fiable (JSON malformado, tipos incorrectos). Usa 422 cuando la petición está bien formada pero falla reglas de negocio (correo inválido, contraseña muy corta).
Usa 409 cuando la entrada es válida pero no se puede aplicar por un conflicto con el estado actual (correo ya registrado, desajuste de versión). Usa 422 para validaciones a nivel de campo donde cambiar el valor lo arregla sin necesitar otro estado del servidor.
Crea un pequeño conjunto de errores tipados (validación, not found, conflicto, no autorizado, interno) y haz que los controladores los retornen. Luego usa un traductor compartido para mapear esos tipos a códigos de estado y a la forma JSON estándar.
Devuelve siempre un request_id en cada respuesta, éxito o fallo, y regístralo en cada línea de log del servidor. Si un cliente reporta un problema, ese único ID suele ser suficiente para encontrar la ruta exacta de fallo en los logs.
Devuelve 200 solo cuando la operación tuvo éxito; usa 4xx/5xx para errores. Ocultar errores tras un 200 obliga a los clientes a parsear campos del cuerpo y provoca comportamientos inconsistentes entre endpoints.
Por defecto, no reintentar para 400, 401, 403, 404, 409 y 422 porque los reintentos no ayudarán sin cambios. Permite reintentos para 503, y a veces para 429 tras esperar; si soportas claves de idempotencia, los reintentos son más seguros para POST en fallos transitorios.
Asegura el contrato con unas pocas pruebas “golden” que verifiquen estado, error.code y presencia de request_id. Añade nuevos códigos de error sin cambiar los significados antiguos y solo agrega campos opcionales para que clientes antiguos sigan funcionando.