Patterns de gestion des erreurs pour API Go qui standardisent les erreurs typées, les codes HTTP, les request IDs et les messages sûrs sans divulguer les internes.

Quand chaque endpoint rapporte les échecs différemment, les clients cessent de faire confiance à votre API. Une route renvoie { "error": "not found" }, une autre renvoie { "message": "missing" }, et une troisième envoie du texte brut. Même si le sens est proche, le code client doit maintenant deviner ce qui s'est passé.
Le coût apparaît rapidement. Les équipes écrivent une logique d'analyse fragile et ajoutent des cas spéciaux par endpoint. Les réessais deviennent risqués car le client ne peut pas distinguer « réessayer plus tard » de « votre saisie est incorrecte ». Les tickets de support augmentent parce que le client voit un message vague, et votre équipe ne peut pas facilement le rattacher à une ligne de logs côté serveur.
Un scénario courant : une application mobile appelle trois endpoints pendant l'inscription. Le premier renvoie HTTP 400 avec une carte d'erreurs au niveau des champs, le deuxième renvoie HTTP 500 avec une trace de pile sous forme de chaîne, et le troisième renvoie HTTP 200 avec { "ok": false }. L'équipe de l'app publie trois handlers d'erreur différents, et votre équipe backend reçoit toujours des rapports du type « l'inscription échoue parfois » sans indice clair par où commencer.
L'objectif est un seul contrat prévisible. Les clients doivent pouvoir lire de façon fiable ce qui s'est passé : si c'est de leur faute ou la vôtre, si un réessai est pertinent, et un identifiant de requête qu'ils peuvent coller dans le support.
Note sur le périmètre : ceci se concentre sur les API HTTP JSON (pas gRPC), mais les mêmes idées s'appliquent partout où vous renvoyez des erreurs à d'autres systèmes.
Choisissez un contrat clair pour les erreurs et faites en sorte que chaque endpoint le respecte. « Cohérent » signifie la même forme JSON, la même signification des champs et le même comportement quel que soit le handler qui échoue. Une fois fait, les clients arrêtent de deviner et commencent à gérer les erreurs.
Un contrat utile aide les clients à décider quoi faire ensuite. Pour la plupart des applications, chaque réponse d'erreur doit répondre à trois questions :
Un ensemble de règles pratiques :
Décidez à l'avance ce qui ne doit jamais apparaître dans les réponses. Les éléments courants à bannir incluent fragments SQL, traces de pile, noms d'hôtes internes, secrets et chaînes d'erreur brutes provenant de dépendances.
Gardez une séparation propre : un message court destiné à l'utilisateur (sûr, poli, actionnable) et des détails internes (erreur complète, pile et contexte) conservés dans les logs. Par exemple, « Impossible d'enregistrer vos modifications. Veuillez réessayer. » est sûr. « pq: duplicate key value violates unique constraint users_email_key » ne l'est pas.
Quand tous les endpoints suivent le même contrat, les clients peuvent bâtir un seul gestionnaire d'erreurs réutilisable partout.
Les clients ne peuvent gérer proprement les erreurs que si chaque endpoint répond avec la même forme. Choisissez une enveloppe JSON et maintenez-la stable.
Un défaut pratique est un objet error plus un request_id au niveau supérieur :
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
Le statut HTTP donne la catégorie large (400, 401, 409, 500). Le error.code lisible par machine donne le cas spécifique sur lequel le client peut faire un branchement. Cette séparation compte parce que beaucoup de problèmes différents partagent le même statut. Une app mobile peut afficher des UI différentes pour EMAIL_TAKEN vs WEAK_PASSWORD, même si les deux renvoient 400.
Conservez error.message sûr et destiné à un humain. Il doit aider l'utilisateur à corriger le problème, mais ne jamais divulguer d'internes (SQL, traces, noms de fournisseurs, chemins de fichiers).
Les champs optionnels sont utiles lorsqu'ils restent prévisibles :
details.fields comme une map champ → message.details.retry_after_seconds.details.docs_hint en texte brut (pas une URL).Pour la compatibilité ascendante, traitez les valeurs de error.code comme faisant partie de votre contrat d'API. Ajoutez de nouveaux codes sans changer les anciens. N'ajoutez que des champs optionnels et supposez que les clients ignorent les champs inconnus.
La gestion des erreurs devient chaotique quand chaque handler invente sa propre manière de signaler un échec. Un petit ensemble d'erreurs typées règle cela : les handlers retournent des types d'erreur connus, et une couche de réponse transforme ces erreurs en réponses cohérentes.
Un ensemble de départ pratique couvre la plupart des endpoints :
L'important est la stabilité au niveau supérieur, même si la cause racine change. Vous pouvez envelopper des erreurs de bas niveau (SQL, réseau, parsing JSON) tout en retournant le même type public que le middleware peut détecter.
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 }
Dans votre handler, retournez NotFoundError{Resource: "user", ID: id, Err: err} au lieu de divulguer sql.ErrNoRows directement.
Pour vérifier les erreurs, préférez errors.As pour les types personnalisés et errors.Is pour les erreurs sentinelles. Les erreurs sentinelles (comme var ErrUnauthorized = errors.New("unauthorized")) conviennent aux cas simples, mais les types personnalisés l'emportent quand vous avez besoin d'un contexte sûr (comme quelle ressource manquait) sans changer votre contrat public de réponse.
Soyez strict sur ce que vous attachez :
Err sous-jacente, info de pile, erreurs SQL brutes, tokens, données utilisateur.Cette séparation vous permet d'aider les clients sans exposer les internals.
Une fois que vous avez des erreurs typées, l'étape suivante est ennuyeuse mais essentielle : le même type d'erreur doit toujours produire le même statut HTTP. Les clients construiront de la logique autour de cela.
Un mapping pratique qui fonctionne pour la plupart des API :
| Type d'erreur (exemple) | Statut | Quand l'utiliser |
|---|---|---|
| BadRequest (JSON malformé, paramètre de query requis manquant) | 400 | La requête n'est pas valide au niveau protocole ou format. |
| Unauthenticated (token absent/invalide) | 401 | Le client doit s'authentifier. |
| Forbidden (pas la permission) | 403 | L'auth est valide, mais l'accès est refusé. |
| NotFound (ID de ressource inexistant) | 404 | La ressource demandée n'existe pas (ou vous choisissez de cacher son existence). |
| Conflict (contrainte unique, mismatch de version) | 409 | La requête est bien formée mais en conflit avec l'état courant. |
| ValidationFailed (règles métier sur les champs) | 422 | La forme est correcte, mais la validation métier échoue (format email, longueur min). |
| RateLimited | 429 | Trop de requêtes sur une fenêtre temporelle. |
| Internal (erreur inconnue) | 500 | Bug ou échec inattendu. |
| Unavailable (dépendance down, timeout, maintenance) | 503 | Problème temporaire côté serveur. |
Deux distinctions qui évitent beaucoup de confusion :
Des indications sur les réessais :
Un request ID est une valeur courte et unique qui identifie un appel API de bout en bout. Si les clients peuvent la voir dans chaque réponse, le support devient simple : « Envoyez-moi le request ID » suffit souvent pour trouver les logs exacts et l'échec exact.
Cette habitude rapporte aussi bien pour les réponses réussies que pour les erreurs.
Utilisez une règle claire : si le client envoie un ID, conservez-le. Sinon, créez-en un.
X-Request-Id).Placez le request ID à trois endroits :
request_id dans votre schéma standard)Pour les endpoints batch ou les jobs en arrière-plan, conservez un request ID parent. Exemple : un client téléverse 200 lignes, 12 échouent en validation, et vous mettez en file. Renvoyez un request_id pour l'appel global, et incluez un parent_request_id sur chaque job et chaque erreur par item. Ainsi, vous pouvez tracer « un seul upload » même quand il se déploie en de nombreuses tâches.
Les clients ont besoin d'une réponse d'erreur claire et stable. Vos logs ont besoin de la vérité complète et désordonnée. Séparez ces deux mondes : renvoyez un message public sûr et un code d'erreur public, tout en consignant la cause interne, la pile et le contexte côté serveur.
Consignez un événement structuré pour chaque réponse d'erreur, consultable par request_id.
Champs utiles à garder constants :
Stockez les détails internes seulement dans les logs serveur (ou un store d'erreurs interne). Le client ne doit jamais voir d'erreurs SQL brutes, de requêtes, de traces de pile ou de messages fournisseurs. Si vous exécutez plusieurs services, un champ interne comme source (api, db, auth, upstream) peut accélérer le triage.
Surveillez les endpoints bruyants et les erreurs de type rate-limit. Si un endpoint peut produire des milliers de 429 ou 400 par minute, évitez le spam de logs : échantillonnez les événements répétés, ou baissez leur sévérité tout en continuant de compter ces erreurs dans les métriques.
Les métriques détectent les problèmes plus tôt que les logs. Suivez les comptes groupés par statut HTTP et code d'erreur, et alertez sur des pics soudains. Si RATE_LIMITED augmente de 10x après un déploiement, vous le verrez rapidement même si les logs sont échantillonnés.
La façon la plus simple de rendre les erreurs cohérentes est d'arrêter de les gérer « partout » et de les faire passer par un petit pipeline. Ce pipeline décide ce que le client voit et ce que vous conservez pour les logs.
Commencez par un petit ensemble de codes d'erreur sur lesquels les clients peuvent compter (par exemple : INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Enveloppez-les dans une erreur typée qui n'expose que des champs publics sûrs (code, message sûr, détails optionnels comme quel champ est en erreur). Gardez les causes internes privées.
Puis implémentez une fonction de traduction qui transforme n'importe quelle erreur en (statusCode, responseBody). C'est là que les erreurs typées sont mappées aux statuts HTTP, et les erreurs inconnues deviennent une réponse 500 sûre.
Ensuite, ajoutez un middleware qui :
request_idUn panic ne doit jamais déverser la trace de pile vers le client. Retournez une réponse 500 normale avec un message générique, et consignez le panic complet avec le même request_id.
Enfin, changez vos handlers pour qu'ils retournent une error au lieu d'écrire la réponse directement. Un wrapper peut appeler le handler, exécuter le traducteur et écrire du JSON dans le format standard.
Une checklist compacte :
Les tests golden sont importants car ils verrouillent le contrat. Si quelqu'un change plus tard un message ou un statut, les tests échouent avant que les clients ne soient surpris.
Imaginez un endpoint : une app cliente crée un enregistrement client.
POST /v1/customers avec JSON comme { "email": "[email protected]", "name": "Pat" }. Le serveur renvoie toujours la même forme d'erreur et inclut toujours un request_id.
L'email est manquant ou mal formaté. Le client peut mettre en évidence le champ.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
L'email existe déjà. Le client peut proposer de se connecter ou de choisir un autre email.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Une dépendance est indisponible. Le client peut réessayer avec backoff et afficher un message calme.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Avec un seul contrat, le client réagit de façon cohérente :
details.fieldsrequest_id comme identifiant de supportPour le support, ce même request_id est le chemin le plus rapide vers la cause réelle dans les logs internes, sans exposer de traces de pile ou d'erreurs de base de données.
La façon la plus rapide d'ennuyer les clients API est de les faire deviner. Si un endpoint renvoie { "error": "..." } et un autre { "message": "..." }, chaque client devient un amas de cas spéciaux, et les bugs se cachent pendant des semaines.
Quelques erreurs reviennent souvent :
code stable sur lequel les clients peuvent se baser.request_id seulement sur les échecs, empêchant la corrélation d'un rapport utilisateur avec l'appel réussi qui a déclenché un problème ultérieur.La fuite d'internes est le piège le plus simple : un handler renvoie err.Error() parce que c'est pratique, puis un nom de contrainte ou un message tiers finit dans une réponse en production. Gardez le message client sûr et court, et mettez la cause détaillée dans les logs.
Se baser uniquement sur du texte est un autre problème qui s'installe lentement. Si le client doit parser des phrases en anglais comme « email already exists », vous ne pouvez pas changer la formulation sans casser la logique. Les codes d'erreur stables vous permettent d'ajuster les messages, de les traduire et de conserver un comportement cohérent.
Considérez les codes d'erreur comme partie intégrante de votre contrat public. Si vous devez en changer un, ajoutez un nouveau code et conservez l'ancien en fonctionnement pendant un certain temps, même si les deux correspondent au même statut HTTP.
Enfin, incluez le même champ request_id dans chaque réponse, succès ou échec. Quand un utilisateur dit « ça marchait, puis ça a planté », cet ID sauve souvent une heure de recherche.
Avant la release, faites une vérification rapide pour la cohérence :
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Ajoutez des tests pour empêcher les handlers de renvoyer des codes inconnus par accident.request_id et consignez-le pour chaque requête, y compris les panics et les timeouts.Après cela, vérifiez manuellement quelques endpoints. Déclenchez une erreur de validation, un enregistrement manquant et une erreur inattendue. Si les réponses diffèrent selon les endpoints (champs qui changent, statuts incohérents, messages qui divulguent trop), corrigez le pipeline partagé avant d'ajouter plus de fonctionnalités.
Règle pratique : si un message aiderait un attaquant ou perturberait un utilisateur normal, il appartient aux logs, pas à la réponse.
Rédigez le contrat d'erreur que vous voulez que chaque endpoint suive, même si votre API est déjà en production. Un contrat partagé (statut, code d'erreur stable, message sûr et request_id) est le moyen le plus rapide de rendre les erreurs prévisibles pour les clients.
Ensuite, migrez progressivement. Conservez vos handlers existants, mais faites passer leurs échecs par un mappeur qui transforme les erreurs internes en votre forme publique. Cela améliore la cohérence sans un refactor risqué, et empêche les nouveaux endpoints d'inventer de nouveaux formats.
Gardez un petit catalogue de codes d'erreur et traitez-le comme partie de votre API. Quand quelqu'un veut ajouter un code, faites une revue rapide : est-ce vraiment nouveau, le nom est-il clair, et est-ce que ça mappe au bon statut HTTP ?
Ajoutez quelques tests pour détecter la dérive :
request_id.error.code est présent et provient du catalogue.error.message reste sûr et n'inclut jamais de détails internes.Si vous construisez un backend Go depuis zéro, il est utile de verrouiller le contrat tôt. Par exemple, Koder.ai (koder.ai) inclut un mode planning où vous pouvez définir des conventions comme un schéma d'erreur et un catalogue de codes en amont, puis garder les handlers alignés à mesure que l'API grandit.
Utilisez une seule forme JSON pour toutes les réponses d'erreur, sur tous les endpoints. Un choix pratique est d'avoir un request_id au niveau racine plus un objet error contenant code, message et des details optionnels afin que les clients puissent analyser et réagir de façon fiable.
Renvoyez error.message sous la forme d'une phrase courte et sûre pour l'utilisateur et conservez la cause réelle dans les logs serveur. Ne renvoyez pas d'erreurs brutes de base de données, de traces de pile, d'hostnames internes ou de messages de dépendances, même si c'est pratique en développement.
Utilisez un error.code stable pour la logique machine et laissez le statut HTTP décrire la catégorie générale. Les clients doivent se baser sur error.code (par exemple ALREADY_EXISTS) et traiter le statut comme un guide (par ex. 409 signifie conflit d'état).
Utilisez 400 lorsque la requête ne peut pas être analysée ou interprétée de façon fiable (JSON malformé, types incorrects). Utilisez 422 lorsque la requête est bien formée mais viole des règles métier (format d'email invalide, mot de passe trop court).
Utilisez 409 lorsque l'entrée est valide mais ne peut pas être appliquée parce qu'elle entre en conflit avec l'état actuel (email déjà pris, incompatibilité de version). Utilisez 422 pour des validations au niveau des champs où changer la valeur corrige le problème sans modifier l'état serveur.
Créez un petit ensemble d'erreurs typées (validation, introuvable, conflit, non autorisé, interne) et faites en sorte que les handlers les retournent. Ensuite, utilisez un traducteur partagé pour mapper ces types aux codes de statut et à la forme JSON standard de réponse.
Retournez toujours un request_id dans chaque réponse, succès ou échec, et consignez-le sur chaque ligne de log serveur. Si un client signale un problème, cet ID suffit souvent à retrouver le chemin exact de l'échec dans les logs.
Retourner 200 avec { ok: false } est une mauvaise idée car 200 doit signifier réussite. Cacher des erreurs derrière 200 force les clients à analyser le corps et crée un comportement incohérent entre endpoints.
Par défaut, ne pas relancer pour 400, 401, 403, 404, 409 et 422 car cela n'aidera pas sans modification. Autoriser les réessais pour 503, et parfois pour 429 après attente ; si vous supportez des clés d'idempotence, les réessais deviennent plus sûrs pour les POST sur des erreurs transitoires.
Verrouillez le contrat avec quelques tests « golden » qui vérifient le statut, error.code et la présence de request_id. Ajoutez de nouveaux codes d'erreur sans changer le sens des anciens, et n'ajoutez que des champs optionnels pour que les clients plus anciens continuent de fonctionner.