Les erreurs traversent le réseau
Reprenons cette ligne, écrite au chapitre 31, et que j'avais promis de ne pas garder :
if (result.error === "Invalid or expired session") {
logOut();
}Une comparaison de chaîne, en français approximatif, entre deux couches. Elle casse à la première reformulation du message, et elle est impossible à traduire.
Le moment est venu de faire de nos erreurs des données.
Ce qu'est une erreur métier
Depuis le chapitre 10, nos erreurs sont des fonctions nommées :
export function StayMustStartInTheFuture(today) {
return new Error(`A stay must start after ${today} (one day notice)`);
}C'était un bon choix : le nom est explicite, l'erreur se construit d'un seul endroit, et on repère au premier coup d'œil ce qu'une commande peut refuser.
Mais un Error transporte une seule information exploitable : un texte destiné à un humain. Or trois publics différents attendent trois choses :
- le code appelant veut savoir quoi : reconnecter ? réafficher ? proposer d'autres dates ?
- l'utilisateur veut une phrase dans sa langue
- vous, à 3 h du matin voulez le contexte : quel logement, quelles dates, quel utilisateur
Une chaîne ne peut pas servir les trois. Donnons-leur trois champs :
// domain/errors.js
export function DomainError(code, details = {}) {
return { name: "DomainError", code, details };
}Et nos fabriques deviennent :
export const StayMustStartInTheFuture = (today) =>
DomainError("STAY_MUST_START_IN_THE_FUTURE", { today: today.toString() });
export const AccommodationNotAvailable = (accommodationId) =>
DomainError("ACCOMMODATION_NOT_AVAILABLE", { accommodationId });
export const AccommodationTooSmall = (accommodationId, capacity, guests) =>
DomainError("ACCOMMODATION_TOO_SMALL", { accommodationId, capacity, guests });
export const InvalidSession = () => DomainError("INVALID_SESSION");Le refactoring est mécanique, et les tests le pilotent : expect(session.error).toEqual(AccommodationNotAvailable("accommodation-1")) continue de fonctionner sans changer une ligne, puisqu'ils comparent le résultat de la fabrique et non son contenu.
C'est, au passage, la meilleure justification rétrospective de la règle du chapitre 10 : ne construisez jamais une erreur à la main. Trente chapitres plus tard, on change leur nature en modifiant un seul fichier.
Trois propriétés à retenir sur le code :
Il est stable. On peut reformuler le message autant qu'on veut ; le code, jamais. C'est un contrat, au même titre qu'une route d'API.
Il est en majuscules et sans espace. Ce n'est pas une phrase, c'est une valeur. La confusion entre les deux est ce qui produit les comparaisons de chaînes du chapitre 31.
Il ne contient pas de donnée variable. ACCOMMODATION_TOO_SMALL, pas ACCOMMODATION_3_TOO_SMALL. Ce qui varie va dans details.
Le texte n'est pas au serveur
Où est passé le message en français ?
Dans le frontend. C'est le seul endroit qui sait à qui il parle.
// webapp/src/errorMessages.js
export const messages = {
INVALID_CREDENTIALS: () => "Email ou mot de passe incorrect.",
INVALID_SESSION: () => "Votre session a expiré. Merci de vous reconnecter.",
ACCOMMODATION_NOT_AVAILABLE: () => "Ce logement vient d'être réservé. Essayez d'autres dates.",
ACCOMMODATION_TOO_SMALL: ({ capacity, guests }) =>
`Ce logement accueille ${capacity} personnes, vous êtes ${guests}.`,
STAY_MUST_LAST_AT_LEAST_ONE_NIGHT: () => "Le départ doit être au moins le lendemain de l'arrivée.",
STAY_MUST_START_IN_THE_FUTURE: () => "La réservation doit être faite au moins un jour à l'avance.",
};
export function toMessage(error) {
const template = messages[error?.code];
return template ? template(error.details ?? {}) : "Une erreur est survenue. Réessayez.";
}Trois bénéfices immédiats.
Le message est écrit là où on peut le relire dans son contexte, et le faire relire par quelqu'un dont c'est le métier. Les messages d'erreur sont de l'interface, pas de la technique.
La traduction devient un fichier de plus, sans toucher au serveur.
Et le toMessage par défaut garantit qu'un code inconnu — une version du serveur plus récente que celle du navigateur, ce qui arrive tous les jours en production — n'affiche jamais undefined.
Le contrôleur traduit en HTTP
Le chapitre 40 avait laissé un respondWithError en suspens. Le voici :
// api/respondWithError.js
const statusByCode = {
INVALID_CREDENTIALS: 401,
INVALID_SESSION: 401,
SHOULD_BE_LOGGED: 401,
UNKNOWN_BOOKING: 404,
UNKNOWN_ACCOMMODATION: 404,
ACCOMMODATION_NOT_AVAILABLE: 409,
BOOKING_ALREADY_CANCELLED: 409,
STAY_ALREADY_STARTED: 409,
};
export function respondWithError(response, error, requestId) {
if (error?.name !== "DomainError") {
// Un bug, pas un cas métier : on journalise tout, on ne dit rien.
logger.error({ error, requestId }, "unexpected");
return response.status(500).json({ error: { code: "INTERNAL_ERROR", requestId } });
}
const status = statusByCode[error.code] ?? 422;
return response.status(status).json({
error: { code: error.code, details: error.details, requestId },
});
}Quelques repères sur le choix des codes, parce que la question occupe des réunions entières pour un enjeu modeste :
- 401 : je ne sais pas qui vous êtes. Reconnectez-vous.
- 403 : je sais qui vous êtes, et vous n'avez pas le droit.
- 404 : ça n'existe pas — ou ça ne vous concerne pas (chapitre 33 : on ne confirme pas l'existence d'une ressource à qui n'y a pas droit).
- 409 : votre demande est bien formée, mais l'état du système s'y oppose. C'est le code des invariants : logement pris, réservation déjà annulée.
- 422 : votre demande est mal formée. Dates illisibles, zéro adulte.
Le repère utile : 400 et 422 disent "recommencez autrement", 409 dit "recommencez plus tard, ou ailleurs". Ce n'est pas la même chose pour l'utilisateur, et c'est la seule raison de les distinguer.
Notez surtout le premier if. Il sépare définitivement deux mondes :
Une erreur métier est prévue, nommée, envoyée au client. Une erreur inattendue est un bug : journalisée intégralement, résumée en une ligne au client.
Renvoyer une pile d'appels, c'est offrir la carte de son système : noms de tables, chemins de fichiers, versions des bibliothèques.
Le requestId
Vous avez vu passer un requestId dans la réponse. C'est cinq lignes de code et cela change la vie du support :
server.use((request, response, next) => {
request.id = request.headers["x-request-id"] ?? crypto.randomUUID();
response.setHeader("x-request-id", request.id);
next();
});L'utilisateur écrit : "ça n'a pas marché, il y avait le code a3f9c1". Vous cherchez cet identifiant dans les logs et vous avez la requête complète, avec sa trace.
Sans lui, vous cherchez "une erreur, hier, vers 15 h, chez quelqu'un". Affichez-le dans votre page d'erreur.
Le client réagit au code
La comparaison de chaîne peut enfin disparaître :
const onBook = async (accommodationId) => {
try {
await api.book({ accommodationId, ...criteria });
setError(null);
await refresh();
} catch (error) {
if (error.status === 401) return logOut(); // session expirée
if (error.code === "ACCOMMODATION_NOT_AVAILABLE") {
setError(toMessage(error));
await refresh(); // la liste est périmée
return;
}
setError(toMessage(error));
}
};Regardez le cas ACCOMMODATION_NOT_AVAILABLE : il affiche et recharge.
C'est le seul cas où l'erreur nous apprend quelque chose sur l'écran lui-même — il montre un logement qui n'existe plus dans cet état. Laisser la carte affichée invite l'utilisateur à recliquer, et à réobtenir la même erreur.
Une erreur métier bien nommée permet de décider quoi faire. C'était tout l'objet du chapitre.
Le cas sans réponse
Reste le pire cas du chapitre 41 : la requête part, le serveur réserve, la connexion tombe avant la réponse.
L'utilisateur voit une erreur réseau. A-t-il réservé ? Il n'en sait rien. Il reclique. Il réserve deux fois — sur deux périodes différentes, notre contrainte du chapitre 36 ne l'en empêchera pas.
La parade s'appelle une clé d'idempotence, et c'est le même raisonnement que le "au moins une fois" du chapitre 35, vu de l'autre bout.
Le client fabrique un identifiant unique par intention, pas par tentative :
const [attemptId] = useState(() => crypto.randomUUID());
await api.book({ accommodationId, ...criteria }, { idempotencyKey: attemptId });Le serveur, dans la transaction du chapitre 39, enregistre la clé avec le résultat :
create table idempotency_keys (
key text primary key,
response jsonb not null,
created_at timestamptz not null default now()
);Si la clé existe déjà, il ne rejoue rien : il renvoie la réponse enregistrée.
Comme elle est écrite dans la même transaction que la réservation, il n'y a pas de fenêtre : soit les deux existent, soit aucune.
Faut-il le faire partout ? Non. Le critère est simple :
Toute commande dont le rejeu coûte quelque chose à quelqu'un mérite une clé d'idempotence.
Une réservation, un paiement, un envoi de commande : oui. Une annulation : inutile, annuler deux fois donne le même état — elle est naturellement idempotente. Une déconnexion : idem, et le chapitre 31 en avait déjà tiré parti.
Et corollaire immédiat sur le client :
On ne rejoue jamais automatiquement un POST sans clé d'idempotence. Les GET peuvent être relancés à volonté — c'est le tableau du chapitre 4, remonté jusque dans la couche réseau. Une bibliothèque cliente qui rejoue tout après un délai d'attente est un générateur de doublons.
Ce qu'on n'affiche jamais
Trois habitudes à prendre, pour finir.
Ne jamais afficher error.message brut. Il est en anglais, il est écrit pour vous, et il contient parfois un identifiant technique. toMessage(error) ou rien.
Ne jamais laisser un catch vide. Une erreur avalée est une fonctionnalité qui ne marche pas sans que personne ne le sache. C'était déjà le sujet du chapitre 26 : un échec silencieux, pour l'utilisateur, c'est un bouton qui ne marche pas.
Ne jamais afficher une erreur sans dire quoi faire. "Erreur 409" n'aide personne. "Ce logement vient d'être réservé. Essayez d'autres dates." dit ce qui s'est passé et ce qui reste possible.
Nos erreurs sont des données, elles traversent le réseau, elles s'affichent dans la bonne langue et se journalisent avec leur contexte.
Il est temps de faire vérifier tout cela par la machine.
Le code de cette étape est disponible ici (opens in a new tab).