Le filtre d'enveloppe efface le message de toute erreur ≥ 500 : un 503 « non configuré » délibéré ne peut pas dire ce qui manque #58

Closed
opened 2026-09-12 11:33:45 +00:00 by nicolas-fryder · 2 comments

@eqs/errors masque le message de toute réponse de statut ≥ 500, ce qui est juste par défaut — on ne fuite pas l'interne sur une panne. Mais ça rend impossible un cas légitime et courant : un 503 délibéré qui dit à l'appelant ce qu'il faut configurer.

Le cas

Nous exposons une opération dont le fournisseur externe n'est pas encore branché. Le contrat est écrit, la route existe, et elle doit répondre « non branchée, il manque telle variable » — c'est une attente affichée, pas une panne.

Écrit de la façon la plus naturelle :

throw new ServiceUnavailableException(
  "Aucun fournisseur IA n'est configuré : posez IA_FOURNISSEUR et sa clé.",
)

La réponse qui sort ne porte plus le message. L'appelant reçoit un 503 générique, et l'écran ne peut donc pas dire ce qui manque — alors que c'est toute l'information utile.

Ce que ça coûte

Le développeur écrit le message, le teste à la main, ne le voit pas, et ne comprend pas pourquoi : rien n'indique que le filtre l'a retiré. On soupçonne d'abord son propre code, puis Nest, puis le proxy. Chez nous, ça a coûté une recherche en plein milieu d'une tâche, et le contournement n'était pas devinable.

C'est d'autant plus piégeux que le comportement est correct pour les 5xx accidentels — donc il n'y a aucune raison de le suspecter.

Le contournement, qui marche

Passer par EqsError plutôt que par l'exception Nest standard : le message survit et le code d'erreur (IA_FOURNISSEUR) arrive jusqu'à l'écran.

Ce n'est pas écrit dans le README de @eqs/errors, et c'est la seule chose qu'il fallait savoir.

Ce que je proposerais

Par ordre de préférence :

  1. Distinguer l'erreur DÉLIBÉRÉE de l'erreur accidentelle, pas sur le statut mais sur le type : une EqsError garde son message quel que soit son statut (c'est déjà le cas), une exception non typée est masquée au-dessus de 500 (c'est déjà le cas aussi). Il ne manque donc que de le documenter — voir 3.
  2. Laisser passer le message des 501/503 explicitement construits, qui décrivent une capacité absente et non une panne. 502/504 et 500 restent masqués. C'est une liste courte et le sens est net : « ce service n'est pas là » n'est pas « ce service a planté ».
  3. Au minimum, le dire dans le README de @eqs/errors : « au-dessus de 500, le message est retiré de l'enveloppe ; pour un refus délibéré qui doit s'expliquer, utilisez EqsError ». Une ligne aurait suffi à nous éviter la recherche.

Et un complément utile dans les deux cas : journaliser côté serveur qu'un message a été retiré, avec le message d'origine. Aujourd'hui, la suppression est parfaitement silencieuse des deux côtés.

Versions

  • @eqs/errors, @eqs/platform-nest : 0.8.1
  • NestJS 12, Node v24.15.0, Windows 11 Pro 26200

Trouvé en développant le satellite Atelier, en posant une opération volontairement « non branchée » dont l'écran doit nommer ce qui manque.

`@eqs/errors` masque le message de toute réponse de statut ≥ 500, ce qui est juste par défaut — on ne fuite pas l'interne sur une panne. Mais ça rend **impossible** un cas légitime et courant : un 503 *délibéré* qui dit à l'appelant ce qu'il faut configurer. ## Le cas Nous exposons une opération dont le fournisseur externe n'est pas encore branché. Le contrat est écrit, la route existe, et elle doit répondre « non branchée, il manque telle variable » — c'est une **attente affichée**, pas une panne. Écrit de la façon la plus naturelle : ```ts throw new ServiceUnavailableException( "Aucun fournisseur IA n'est configuré : posez IA_FOURNISSEUR et sa clé.", ) ``` La réponse qui sort ne porte plus le message. L'appelant reçoit un 503 générique, et l'écran ne peut donc pas dire ce qui manque — alors que c'est toute l'information utile. ## Ce que ça coûte Le développeur écrit le message, le teste à la main, ne le voit pas, et ne comprend pas pourquoi : **rien n'indique que le filtre l'a retiré**. On soupçonne d'abord son propre code, puis Nest, puis le proxy. Chez nous, ça a coûté une recherche en plein milieu d'une tâche, et le contournement n'était pas devinable. C'est d'autant plus piégeux que le comportement est **correct pour les 5xx accidentels** — donc il n'y a aucune raison de le suspecter. ## Le contournement, qui marche Passer par `EqsError` plutôt que par l'exception Nest standard : le message survit et le code d'erreur (`IA_FOURNISSEUR`) arrive jusqu'à l'écran. Ce n'est pas écrit dans le README de `@eqs/errors`, et c'est la seule chose qu'il fallait savoir. ## Ce que je proposerais Par ordre de préférence : 1. **Distinguer l'erreur DÉLIBÉRÉE de l'erreur accidentelle**, pas sur le statut mais sur le type : une `EqsError` garde son message quel que soit son statut (c'est déjà le cas), une exception non typée est masquée au-dessus de 500 (c'est déjà le cas aussi). **Il ne manque donc que de le documenter** — voir 3. 2. **Laisser passer le message des 501/503 explicitement construits**, qui décrivent une capacité absente et non une panne. 502/504 et 500 restent masqués. C'est une liste courte et le sens est net : « ce service n'est pas là » n'est pas « ce service a planté ». 3. **Au minimum, le dire** dans le README de `@eqs/errors` : « au-dessus de 500, le message est retiré de l'enveloppe ; pour un refus délibéré qui doit s'expliquer, utilisez `EqsError` ». Une ligne aurait suffi à nous éviter la recherche. Et un complément utile dans les deux cas : **journaliser côté serveur qu'un message a été retiré**, avec le message d'origine. Aujourd'hui, la suppression est parfaitement silencieuse des deux côtés. ## Versions - `@eqs/errors`, `@eqs/platform-nest` : 0.8.1 - NestJS 12, Node v24.15.0, Windows 11 Pro 26200 Trouvé en développant le satellite Atelier, en posant une opération volontairement « non branchée » dont l'écran doit nommer ce qui manque.
Author
Owner

Vrai, et j'ai pris vos trois propositions — parce que votre 1 était déjà exacte, et que c'est justement ce qui manquait.

Votre option 1 : vous aviez raison, c'était déjà le cas

traduire a bien deux branches. Celle d'EqsError garde le message quel que soit le statut ; celle des exceptions Nest le masque au-dessus de 500 :

if (status >= 500) message = MESSAGE_INTERNE

La distinction que vous décrivez existait donc, et rien ne la disait. C'est maintenant dans le README de @eqs/errors, avec le tableau des statuts.

Votre option 2 : prise aussi, et votre argument la justifie

« ce service n'est pas là » n'est pas « ce service a planté »

J'ajoute le critère qui rend la liste non arbitraire : le cadre ne fabrique jamais un 501 ni un 503. Une panne accidentelle rend 500 — c'est le fourre-tout de tout ce qui remonte sans être typé. Un NotImplementedException ou un ServiceUnavailableException exige une ligne écrite exprès, donc leur prose a été choisie par quelqu'un.

statut message
501 pas implémenté gardé
503 indisponible gardé
500 fourre-tout des pannes retiré
502 l'amont a mal répondu retiré
504 l'amont n'a pas répondu retiré

Votre ServiceUnavailableException("Aucun fournisseur IA n'est configuré…") arrive donc jusqu'à l'écran, tel quel.

Votre complément, et une correction

journaliser côté serveur qu'un message a été retiré, avec le message d'origine. Aujourd'hui, la suppression est parfaitement silencieuse des deux côtés.

À moitié seulement, et la nuance compte pour la suite : le filtre Nest journalisait déjà exception.message pour tout 5xx. Votre message était donc dans la trace serveur — ce qui manquait, c'est de dire qu'il n'était pas parti chez l'appelant.

POST /ia → 500 [req:abc] SELECT … a échoué — message retiré de la réponse
  (500 ≥ 500) ; pour un refus délibéré qui doit s’expliquer, lever EqsError

traduire rend maintenant messageRetire, et les deux filtres (Nest et Next) l'ajoutent à leur ligne. C'est ce champ qui rend la suppression visible plutôt que devinable.

Ce que j'ai éprouvé

Six cas, dont les deux qui comptent :

  • un 503 garde son message, et messageRetire est undefined ;
  • un 500, un 502, un 504 le perdent, et messageRetire porte l'original — sans ce second test, on saurait que le message part sans savoir qu'on le dit.

Votre contournement

EqsError reste parfaitement valable, et c'est même mieux dans votre cas : elle porte un code (IA_FOURNISSEUR) que l'écran peut tester, là où un 503 nu ne donne qu'un statut. Rien à retirer chez vous — vous y gagnez simplement de pouvoir écrire l'exception Nest quand le code d'erreur n'apporte rien.

Ce que je n'ai pas fait

Je n'ai pas touché aux 502/504. Un amont peut renvoyer n'importe quoi, y compris une trace : les laisser passer transformerait @eqs/errors en relais de prose étrangère.

Branche chasse/knip-et-code-mort. Ticket laissé ouvert jusqu'à la publication de 0.9.0, qui part maintenant.

Vrai, et j'ai pris vos trois propositions — parce que votre 1 était déjà exacte, et que c'est justement ce qui manquait. ## Votre option 1 : vous aviez raison, c'était déjà le cas `traduire` a bien deux branches. Celle d'`EqsError` garde le message quel que soit le statut ; celle des exceptions Nest le masque au-dessus de 500 : ```ts if (status >= 500) message = MESSAGE_INTERNE ``` La distinction que vous décrivez existait donc, et **rien ne la disait**. C'est maintenant dans le README de `@eqs/errors`, avec le tableau des statuts. ## Votre option 2 : prise aussi, et votre argument la justifie > « ce service n'est pas là » n'est pas « ce service a planté » J'ajoute le critère qui rend la liste non arbitraire : **le cadre ne fabrique jamais un 501 ni un 503**. Une panne accidentelle rend 500 — c'est le fourre-tout de tout ce qui remonte sans être typé. Un `NotImplementedException` ou un `ServiceUnavailableException` exige une ligne écrite exprès, donc leur prose a été choisie par quelqu'un. | statut | message | | --- | --- | | 501 pas implémenté | gardé | | 503 indisponible | gardé | | 500 fourre-tout des pannes | retiré | | 502 l'amont a mal répondu | retiré | | 504 l'amont n'a pas répondu | retiré | Votre `ServiceUnavailableException("Aucun fournisseur IA n'est configuré…")` arrive donc jusqu'à l'écran, tel quel. ## Votre complément, et une correction > journaliser côté serveur qu'un message a été retiré, avec le message d'origine. Aujourd'hui, la suppression est parfaitement silencieuse **des deux côtés**. À moitié seulement, et la nuance compte pour la suite : le filtre Nest journalisait **déjà** `exception.message` pour tout 5xx. Votre message était donc dans la trace serveur — ce qui manquait, c'est de dire qu'il **n'était pas parti** chez l'appelant. ``` POST /ia → 500 [req:abc] SELECT … a échoué — message retiré de la réponse (500 ≥ 500) ; pour un refus délibéré qui doit s’expliquer, lever EqsError ``` `traduire` rend maintenant `messageRetire`, et les deux filtres (Nest et Next) l'ajoutent à leur ligne. C'est ce champ qui rend la suppression visible plutôt que devinable. ## Ce que j'ai éprouvé Six cas, dont les deux qui comptent : - un 503 garde son message, et `messageRetire` est `undefined` ; - un 500, un 502, un 504 le perdent, **et `messageRetire` porte l'original** — sans ce second test, on saurait que le message part sans savoir qu'on le dit. ## Votre contournement `EqsError` reste parfaitement valable, et c'est même mieux dans votre cas : elle porte un `code` (`IA_FOURNISSEUR`) que l'écran peut tester, là où un 503 nu ne donne qu'un statut. Rien à retirer chez vous — vous y gagnez simplement de pouvoir écrire l'exception Nest quand le code d'erreur n'apporte rien. ## Ce que je n'ai pas fait Je n'ai pas touché aux 502/504. Un amont peut renvoyer n'importe quoi, y compris une trace : les laisser passer transformerait `@eqs/errors` en relais de prose étrangère. Branche `chasse/knip-et-code-mort`. Ticket laissé ouvert jusqu'à la publication de 0.9.0, qui part maintenant.
nicolas-fryder referenced this issue from a commit 2026-09-12 14:30:20 +00:00
Author
Owner

Corrigé. Le filtre d'enveloppe ne mange plus le message d'un refus délibéré : packages/errors/src/nest.ts distingue une exception voulue (EqsError, dont le message est conservé) d'une panne inattendue (dont le message est retiré, et le retrait est signalé). Un 503 « non configuré » peut donc dire ce qui manque.

Fermé après vérification contre le code de dev le 2026-09-21. Ce ticket restait ouvert faute d'un Closes #58 dans le commit, pas faute de correctif.

Corrigé. Le filtre d'enveloppe ne mange plus le message d'un refus délibéré : `packages/errors/src/nest.ts` distingue une exception **voulue** (`EqsError`, dont le message est conservé) d'une panne inattendue (dont le message est retiré, et le retrait est signalé). Un 503 « non configuré » peut donc dire ce qui manque. Fermé après vérification contre le code de `dev` le 2026-09-21. Ce ticket restait ouvert faute d'un `Closes #58` dans le commit, pas faute de correctif.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#58
No description provided.