Aller au contenu

6. Échecs lisibles par machine

Règle. Une erreur côté serveur produit toujours : {"success": false, "code": "<stable>", "message": "<human>", "details": {...}}. Le client se cale sur code, JAMAIS sur message.

Pourquoi

Une chaîne "User not found" est :

  • Dépendante de la locale (l'i18n change la chaîne).
  • Mutable pour raisons cosmétiques (un dev change "User" en "Profile" pendant un refactor).
  • Non structurée (impossible d'extraire l'identifiant concerné).

Conséquence : le client gère mal les erreurs, ou pire, écrit des regex fragiles sur le message.

L'enveloppe d'erreur canonique

Toute réponse 4xx ou 5xx retourne :

{
  "success": false,
  "code": "mail.invalid_recipient",
  "message": "recipient must be a non-empty email address",
  "details": {
    "received_recipient": "  "
  }
}
Champ Type Stable ? Usage côté client
success false toujours false ici discriminant
code str oui (versionné) if code === "mail.invalid_recipient"
message str non affichage de repli / débogage
details dict \| null structure stable par code extraire des données contextualisées

Conventions de nommage des codes

<domain>.<verb> ou <domain>.<noun> :

Code Signification
users.not_found Ressource absente
users.invalid_email Validation métier
auth.unauthorized Token absent / malformé
auth.forbidden Token valide mais pas le bon scope
transient.database_unavailable DB qui ne répond pas (mappé 503)
transient.upstream_down Service amont KO (mappé 503)
http.validation_error Pydantic ValidationError

Convention : transient.* pour ce qui justifie un retry côté client, <domain>.* pour ce qui ne le justifie pas (correction métier nécessaire).

Les erreurs sont listables

Chaque module documente les codes qu'il émet :

"""Module audit — service d'audit log centralisé.

Codes d'erreur émis :
- ``audit.invalid_actor``    : actor vide
- ``audit.invalid_action``   : action vide
- ``audit.invalid_limit``    : limit hors [1, 1000]
- ``audit.invalid_offset``   : offset < 0
- ``audit.not_found``        : event_id inconnu
"""

Pour un catalogue centralisé multi-services, voir CATALOG_ERREURS.md (sentinel).

Pourquoi success: false plutôt que regarder le statut HTTP ?

Trois raisons :

  1. Lisibilité dans les logs JSON. Un {"success": false, "code": "mail.not_found"} cherche en code:"mail.not_found". Sans le discriminant explicite, il faut lire le status code dans une autre colonne.
  2. Robustesse aux proxies. Un proxy qui réécrit le statut (CDN, WAF, répartiteur de charge mal configuré) ne réécrit pas le corps. Le client garde un signal fiable.
  3. Cohérence avec les succès. L'enveloppe de succès ({"success": true, "data": ...}) a déjà un discriminant. L'erreur en a aussi. Le client a une seule règle d'analyse.

Test miroir : le code est vérifié

Tous les codes d'erreur sont vérifiés en test miroir :

def test_enqueue_invalid_recipient_returns_400(client):
    response = client.post("/api/v1/messages", json={"recipient": "bad", ...})
    assert response.status_code == 400
    assert response.json()["code"] == "mail.invalid_recipient"

Si quelqu'un renomme le code par inadvertance, le test casse — la contrainte est encodée dans le code.