Concevoir un contrat d’erreur robuste pour une API REST
Les réponses d’erreur de votre API font partie de son contrat. Les clients les analysent, les mécanismes de réessai s’appuient sur elles et les équipes support les consultent à 2 heures du matin. Pourtant, beaucoup d’équipes détaillent le chemin nominal et laissent les erreurs dépendre des valeurs par défaut du framework. Résultat : plusieurs formats d’erreur dans une même API, une réponse 200 contenant success: false ou encore une trace de pile qui divulgue le schéma de la base de données.
Essayez Apidog dès aujourd'hui
Ce guide présente une approche de bout en bout : choisir le bon code HTTP, standardiser le corps avec les Problem Details de la RFC 9457, séparer les codes machine des messages humains, indiquer si une erreur peut être réessayée et empêcher toute fuite d’informations sensibles. Il montre également comment tester chaque chemin d’échec dans Apidog.
Commencez par le code d’état, pas par le corps
HTTP fournit déjà une première couche de sémantique. La RFC 9110 définit notamment les familles suivantes :
-
4xx: le client a envoyé une requête incorrecte ; la même requête échouera généralement à nouveau. -
5xx: le serveur ou une dépendance a échoué ; la requête du client peut être valide.
Les clients génériques, les proxies, les caches et les bibliothèques de réessai utilisent ces codes sans lire votre JSON. Consultez la référence MDN des codes d’état HTTP et appliquez une convention cohérente.
| Situation | Utiliser | Ne pas utiliser | Pourquoi |
|---|---|---|---|
| JSON invalide, type de contenu incorrect ou champ obligatoire manquant | 400 Bad Request |
422 |
Le serveur ne peut pas analyser ou comprendre la requête. |
| Requête valide mais règles métier violées : montant négatif, devise non supportée | 422 Unprocessable Content |
400 |
La syntaxe est correcte, mais les valeurs ne le sont pas. |
| Identifiants absents, expirés ou invalides | 401 Unauthorized |
403 |
Le client n’a pas prouvé son identité. Envoyez WWW-Authenticate. |
| Identifiants valides mais permissions insuffisantes | 403 Forbidden |
401 |
L’identité est connue, mais l’accès est refusé. |
| Ressource inexistante ou existence à ne pas confirmer | 404 Not Found |
410 |
Le comportement par défaut masque aussi les ressources aux sondages non autorisés. |
| Ressource supprimée définitivement | 410 Gone |
404 |
Indique aux clients et aux robots de supprimer leurs références. |
| Conflit d’état : doublon, version périmée, collision d’édition | 409 Conflict |
400 |
La requête est valide mais incompatible avec l’état actuel. |
| Limite de débit dépassée | 429 Too Many Requests |
503 |
Ajoutez toujours Retry-After. |
| Exception non gérée dans votre code | 500 Internal Server Error |
502 |
Votre serveur a échoué. |
| Réponse invalide d’un service en amont | 502 Bad Gateway |
500 |
L’échec se situe derrière votre passerelle. |
| Serveur surchargé ou en maintenance | 503 Service Unavailable |
500 |
L’échec est temporaire ; ajoutez Retry-After si possible. |
| Service en amont expiré | 504 Gateway Timeout |
500 |
Distingue une dépendance lente d’un code défectueux. |
Deux distinctions sont particulièrement importantes :
-
401et403ne sont pas interchangeables. Renvoyer403à un appelant non authentifié peut révéler qu’une ressource existe. - Un
429sansRetry-Afterencourage les clients à réessayer immédiatement en boucle. Le guide sur la limitation de débit d’une API détaille les algorithmes et les en-têtes associés.
Utilisez une seule forme : RFC 9457 Problem Details
Une fois le code d’état choisi, toutes les erreurs devraient utiliser le même type de média et le même schéma :
Content-Type: application/problem+json
La RFC 9457 définit cinq membres principaux :
-
type: URI identifiant la catégorie d’erreur ; -
title: résumé court destiné aux humains ; -
status: code HTTP, répété par commodité ; -
detail: description de cette occurrence ; -
instance: URI identifiant l’échec précis.
Les champs supplémentaires sont des membres d’extension. La présentation détaillée de la RFC 9457 explique notamment comment elle remplace la RFC 7807.
Voici une réponse de validation pour un point de terminaison de paiement :
POST /v1/payments HTTP/1.1
Content-Type: application/json
{ "amount": -1400, "currency": "USD", "source": "card_8xKt2" }
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "La validation de la requête a échoué",
"status": 422,
"detail": "Un ou plusieurs champs n'ont pas passé la validation.",
"instance": "/v1/payments/requests/req_9f3c1a7b",
"code": "PAYMENT_VALIDATION_FAILED",
"errors": [
{
"field": "amount",
"code": "AMOUNT_NOT_POSITIVE",
"message": "le montant doit être un entier positif en unités mineures"
}
],
"request_id": "req_9f3c1a7b"
}
errors[] est un membre d’extension particulièrement utile : une interface peut associer chaque erreur au champ exact du formulaire au lieu d’afficher une bannière générique.
Choisissez un format stable pour les chemins, comme JSON Pointer ou une notation pointée, puis utilisez-le partout.
Appliquez cette structure à toutes les erreurs, y compris celles produites par un framework ou une passerelle. Si vos gestionnaires renvoient des Problem Details mais qu’un équilibreur de charge renvoie du HTML pour une erreur 502, les clients devront maintenir deux parseurs.
Séparez les codes machine des messages humains
L’exemple contient volontairement un code et un message. Ces champs répondent à des besoins différents.
Les codes machine, comme AMOUNT_NOT_POSITIVE, CURRENCY_UNSUPPORTED ou IDEMPOTENCY_KEY_REUSED, font partie du contrat. Ils doivent être :
- stables ;
- documentés ;
- énumérables.
Les clients ne doivent jamais analyser le texte d’un message :
// À éviter
if (error.message.includes("positif")) {
// ...
}
Une simple correction éditoriale deviendrait alors un changement cassant.
Les messages humains peuvent évoluer librement. Ils doivent expliquer l’échec et indiquer comment le corriger :
« le montant doit être un entier positif en unités mineures »
est plus utile que :
« montant invalide »
Si vous localisez les messages, gardez les codes inchangés.
Cette séparation est également essentielle pour les consommateurs pilotés par des agents autonomes. Les clients basés sur des LLM se rétablissent mieux avec des erreurs structurées et auto-descriptives ; voir la conception de messages d’erreur pour les agents IA.
Ne divulguez jamais ces informations
Les erreurs non gérées constituent un canal de reconnaissance privilégié. Votre middleware doit empêcher les éléments suivants d’atteindre le client :
- traces de pile, noms de classes et chemins de fichiers ;
- SQL brut, fragments de requête et erreurs ORM ;
- noms d’hôtes internes, adresses IP, ports et noms de services ;
- versions de bibliothèques et signatures de frameworks ;
- secrets, jetons et chaînes de connexion présents dans une exception ;
- existence d’un compte utilisateur, notamment lors de la connexion ou de la réinitialisation d’un mot de passe.
Le modèle recommandé est simple :
- Intercepter l’erreur à la frontière de l’application.
- Journaliser l’exception complète côté serveur avec un identifiant de requête.
- Renvoyer un Problem Details générique contenant ce même identifiant.
{
"type": "https://api.example.com/problems/internal-error",
"title": "Erreur interne",
"status": 500,
"detail": "Une erreur interne est survenue",
"request_id": "req_51ad0"
}
Le client reçoit une information sûre, tandis que les journaux conservent les détails nécessaires au diagnostic.
Indiquez si l’erreur peut être réessayée
Chaque erreur répond à une question importante : « dois-je réessayer ? » Encodez cette décision dans le contrat plutôt que de laisser chaque équipe cliente deviner.
Par défaut :
-
429,502,503et504peuvent être réessayés avec retrait exponentiel et gigue ; -
500peut faire l’objet d’un réessai prudent ; - la plupart des autres
4xxsont terminaux.
Réessayer un 401, 403, 404 ou 422 avec la même requête gaspille du quota et pollue les journaux.
Les délais d’attente nécessitent une attention particulière. Une requête peut avoir abouti après l’abandon du client. Pour les opérations mutables, utilisez donc des clés d’idempotence afin qu’un paiement réessayé ne soit pas facturé deux fois.
Vous pouvez rendre la décision explicite :
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Trop de requêtes",
"status": 429,
"code": "RATE_LIMITED",
"retryable": true,
"retry_after_seconds": 30
}
Le champ retryable permet de remplacer les valeurs par défaut. Par exemple, un sous-code 500 peut être marqué comme terminal s’il existe un risque de corruption d’état. Documentez cette règle une seule fois pour obtenir un comportement uniforme dans vos SDK.
Ajoutez un identifiant de corrélation
Attribuez un identifiant à chaque requête :
- Acceptez
X-Request-Idlorsqu’il est fourni ou générez-en un. - Ajoutez-le à chaque ligne de journal.
- Renvoyez-le dans chaque réponse d’erreur sous le nom
request_id.
Quand un client joint une erreur à un ticket support, cet identifiant transforme une longue recherche dans les journaux en une requête ciblée.
Dans un environnement distribué, propagez également traceparent au format W3C afin de suivre la requête entre les services.
Versionnez le contrat d’erreur
Traitez le contrat d’erreur comme le reste de votre API.
En général, ces changements sont compatibles :
- ajouter un membre d’extension ;
- ajouter un nouveau code d’erreur.
En revanche, ces changements sont cassants :
- renommer
errors[].field; - modifier la signification d’un code existant ;
- remplacer une structure ad hoc par Problem Details.
L’URI type fournit un mécanisme de versionnement propre :
- conservez les anciens URI
typede manière stable ; - introduisez de nouveaux URI pour de nouvelles sémantiques ;
- documentez que les membres d’extension et les codes inconnus doivent être ignorés.
Cette règle de compatibilité ascendante permet de faire évoluer l’API sans créer immédiatement une v2.
Testez chaque chemin d’erreur dans Apidog
Les contrats d’erreur se dégradent lorsqu’ils ne sont jamais exécutés. Le chemin nominal apparaît à chaque démonstration ; une branche 422 n’est souvent déclenchée qu’en production.
Faites donc des scénarios d’échec des citoyens de première classe dans votre suite de tests.
Scénarios côté serveur
Pour chaque point de terminaison, créez au minimum un scénario par cas d’échec :
- authentification absente →
401; - rôle insuffisant →
403; - montant négatif →
422avecerrors[0].code = AMOUNT_NOT_POSITIVE; - trafic excessif →
429avec l’en-têteRetry-After.
Les assertions d’Apidog vérifient l’état, les en-têtes et les champs du corps sans script. Vous pouvez aussi valider toute la charge utile contre votre schéma JSON Problem Details. Un écart fait alors échouer la CI plutôt que la production.
Consultez le guide des assertions API pour les modèles disponibles.
Serveurs de maquette côté client
Les équipes front-end et SDK doivent pouvoir développer les réponses 4xx et 5xx avant que le back-end ne les produise réellement.
Les serveurs de maquette Apidog renvoient les corps Problem Details exacts de votre spécification API. Vous pouvez ainsi simuler :
- un
503avecRetry-After: 120; - un
409après une double soumission ; - une erreur de validation complète avec
errors[].
Observez ensuite l’affichage et le comportement de réessai du client, sans stub Express manuel ni code back-end temporairement commenté.
Concevez le contrat, encodez-le en scénarios et en maquettes, puis exécutez les deux dans la CI. Vous pouvez télécharger Apidog et importer une spécification OpenAPI existante pour obtenir rapidement des réponses d’erreur simulables.
FAQ
Dois-je utiliser 400 ou 422 pour les erreurs de validation ?
Utilisez 400 lorsque la requête est mal formée et incompréhensible : JSON invalide, type de contenu incorrect ou champ obligatoire manquant.
Utilisez 422 lorsque la requête est correctement analysée mais que ses valeurs enfreignent les règles métier, par exemple un montant négatif ou une devise non supportée.
Un 422 signifie « corrigez vos données », tandis qu’un 400 signifie « corrigez le format de la requête ». L’essentiel est d’appliquer la même convention à tous les points de terminaison.
Qu’est-ce que application/problem+json ?
C’est le type de média défini par la RFC 9457 pour les Problem Details, le format JSON standard des erreurs HTTP. Il contient notamment type, title, status, detail et instance, ainsi que vos extensions comme errors[].
Ce type de média permet aux clients génériques et aux middlewares de reconnaître vos erreurs sans configuration personnalisée.
Quelles erreurs HTTP les clients doivent-ils réessayer automatiquement ?
Réessayez 429, 502, 503 et 504 avec un retrait exponentiel et une gigue, en respectant Retry-After lorsqu’il est présent. Traitez 500 comme potentiellement réessayable, mais avec prudence.
Ne réessayez pas les autres réponses 4xx sans modifier la requête. Pour les opérations mutables, utilisez une clé d’idempotence afin qu’une requête rejouée ne puisse pas facturer ou créer deux fois.
Comment tester les réponses d’erreur sans casser le back-end ?
Simulez-les avec un serveur de maquette Apidog qui renvoie les corps 4xx et 5xx définis dans votre spécification. Vérifiez ensuite le rendu et le comportement de réessai du client.
Côté serveur, envoyez des charges invalides, une authentification absente et suffisamment de trafic pour déclencher la limitation, puis validez les codes, les en-têtes et le schéma du corps. Exécutez ces tests dans la CI pour maintenir un contrat d’erreur fiable.
Top comments (1)
Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.
The strongest part of this architecture is treating error responses as a versioned API contract rather than framework output. I would push this further by making error semantics executable across the entire platform.
I prefer a centralized error taxonomy where each domain error maps deterministically to HTTP status, Problem Details type, machine code, retry policy, observability severity, and remediation strategy. This prevents different microservices from independently deciding whether the same failure is retryable.
For distributed systems, I would also propagate traceparent and request_id through every service boundary, while attaching structured error metadata to OpenTelemetry spans. Then a single client failure can be correlated across gateway, service, database and downstream dependency without searching logs manually.
Another important layer is contract testing. Generate negative test cases directly from the OpenAPI specification and assert status, content type, required Problem Details fields, headers and machine codes in CI. Consumer driven contract tests can additionally verify that SDKs never depend on localized detail messages.
For retryable failures, I would avoid relying solely on retryable=true. Combine error classification with an operation deadline, retry budget and idempotency policy. That prevents nested clients from multiplying retries during dependency degradation.
This approach turns error handling from defensive coding into an observable, testable reliability protocol. Excellent writeup, Antoine. I would enjoy exchanging ideas on API architecture and distributed systems with you.