Patrones de manejo de errores en APIs Go para respuestas claras y consistentes
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.

Por qué los errores inconsistentes de la API frustran a los clientes
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.
Un objetivo simple: un contrato que siga cada endpoint
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:
- ¿Puedo corregir mi entrada?
- ¿Debo reintentar más tarde?
- ¿Necesito contactar soporte?
Un conjunto práctico de reglas:
- Una forma de respuesta para todos los errores.
- Una política de códigos de estado (el mismo tipo de error siempre mapea al mismo HTTP status).
- Una política de mensajes seguros (qué ve el usuario vs qué queda interno).
- Un gancho de correlación (un request ID devuelto para que soporte encuentre el fallo).
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.
Define un esquema de respuesta de error en el que los clientes puedan confiar
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:
- Errores de validación:
details.fieldscomo un mapa de campo a mensaje. - Límites de tasa o problemas temporales:
details.retry_after_seconds. - Guía extra:
details.docs_hintcomo 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.
Errores tipados en Go: un modelo limpio para tus handlers
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:
- ValidationError (entrada inválida)
- NotFoundError (recurso ausente)
- ConflictError (restricción única, desajuste de estado)
- UnauthorizedError (no autenticado o sin permiso)
- InternalError (todo lo demás)
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:
- Público (seguro para clientes): un mensaje corto, un código estable y a veces el nombre de un campo para validación.
- Privado (solo logs):
Errsubyacente, info de stack, errores SQL crudos, tokens, datos de usuario.
Esa separación te permite ayudar a clientes sin exponer internals.
Mapear tipos de error a códigos de estado HTTP de forma consistente
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:
- 400 vs 422: usa 400 cuando no puedes interpretar la petición de forma fiable (JSON malformado, tipos incorrectos). Usa 422 cuando puedes parsearla, pero los valores no son aceptables.
- 409 vs 422: usa 422 para validación a nivel de campo (contraseña demasiado corta). Usa 409 cuando los datos son válidos pero no pueden aplicarse por el estado (correo ya tomado, orden ya enviada, fallo de lock optimista).
La guía de reintentos importa:
- Usualmente seguro reintentar: 503, y a veces 429 (tras esperar).
- Usualmente no seguro reintentar sin cambios: 400, 401, 403, 404, 409, 422.
- Si la operación es idempotente (PUT con el mismo body, o POST con una clave de idempotencia), los reintentos son más seguros incluso tras fallos transitorios.
Request IDs: la forma más rápida de depurar problemas de clientes
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.
Reglas de generación y propagación
Usa una regla clara: si el cliente envía un request ID, consérvalo. Si no, créalo.
- Acepta un ID entrante desde un nombre de header único (elige uno y documéntalo, por ejemplo
X-Request-Id). - Si el header falta o está vacío, genera un nuevo ID en el borde (middleware) y añádelo al contexto de la petición.
- Nunca cambies el ID a mitad de la petición. Pásalo a llamadas downstream (BD, otros servicios) via context o headers.
Pon el request ID en tres lugares:
- Header de respuesta (mismo nombre de header que aceptas)
- Cuerpo de respuesta (como
request_iden tu esquema estándar) - Logs (como campo estructurado en cada línea de log)
Trabajo en batch y asíncrono
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.
Logging y métricas sin filtrar internals
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:
- request_id
- user_id o account_id (cuando esté autenticado)
- código de error público y status HTTP
- nombre del handler/route y método
- detalle interno del error (causa envuelta, errores de validación por campo, timeout upstream)
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.
Paso a paso: implementar una canalización de errores consistente en Go
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.
La canalización en 5 pasos prácticos
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:
- asegure que cada request tiene un
request_id - recupere de panics
Un 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:
- Define errores tipados con campos públicos seguros y códigos estables.
- Traduce errores a status y JSON de respuesta en un solo lugar.
- Añade middleware de request ID y recuperación de panics.
- Haz que los handlers retornen errores, no respuestas.
- Añade pruebas golden para el traductor y el wrapper.
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.
Ejemplo: un endpoint, tres fallos, respuestas predecibles
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.
1) Error de validación (400)
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"
}
}
}
}
2) Conflicto (409)
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."
}
}
3) Fallo transitorio (503)
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:
- 400: marcar campos usando
details.fields - 409: guiar al usuario hacia un siguiente paso seguro
- 503: pedir reintentos y mostrar el
request_idcomo ID de soporte
Para 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.
Trampas comunes que empeoran el manejo de errores
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:
- Devolver HTTP 200 con un error en el cuerpo, o cambiar entre múltiples esquemas de error entre endpoints.
- Exponer internals en el mensaje de usuario, como errores SQL, traces, IPs, hostnames de dependencias o rutas de archivos.
- Usar texto humano como único identificador en lugar de un
codeestable que los clientes puedan usar. - Cambiar códigos de error a la ligera (o reutilizar el mismo código para problemas distintos) y romper clientes escritos para el comportamiento anterior.
- Añadir
request_idsolo 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.
Lista rápida antes de lanzar
Antes del release, haz una pasada rápida para consistencia:
- Una forma de error en todas partes. Cada endpoint devuelve los mismos campos JSON (por ejemplo:
error.code,error.message,request_id). - Códigos de error estables y con cobertura. Mantén códigos cortos y sin florituras (
VALIDATION_FAILED,NOT_FOUND,CONFLICT,UNAUTHORIZED). Añade pruebas para que los handlers no devuelvan códigos desconocidos por accidente. - Una regla única de mapeo a status. Decide cómo cada tipo de error se mapea a un status HTTP y aplícalo en un lugar compartido.
- Request ID en ambos sentidos. Devuelve siempre un
request_idy regístralo en cada request, incluidos panics y timeouts. - Mensajes seguros por defecto. Los mensajes para usuarios deben ser cortos, claros y accionables; nunca traces, errores SQL o nombres de proveedores.
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.
Próximos pasos: estandariza ahora y mantén la consistencia
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:
- Cada respuesta de error incluye
request_id. - El status coincide con el tipo de error (no con el texto del error).
error.codeestá presente y proviene del catálogo.error.messagesigue siendo seguro y nunca incluye detalles internos.- Errores desconocidos caen a 500 con un mensaje genérico.
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.
Preguntas frecuentes
¿Cómo debería ser una “respuesta de error consistente”?
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.
¿Cómo evito filtrar detalles internos en errores de la API?
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.
¿Realmente necesito un código de error si ya tengo códigos HTTP?
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).
¿Cuándo debo usar HTTP 400 vs 422?
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).
¿Cuándo debo usar HTTP 409 vs 422?
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.
¿Cómo ayudan los errores tipados en Go a mantener respuestas consistentes?
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.
¿Cómo debo generar y devolver request IDs?
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.
¿Por qué es mala idea devolver HTTP 200 con `{ "ok": false }`?
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.
¿Qué errores deberían reintentar los clientes y cuáles no?
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.
¿Cómo evito que las respuestas de error cambien con la evolución de la API?
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.