batchCooking/specs/error-handling.md
Nicolas 95bd8fab87 refactor: move ErrorHandlerService/HttpError out of express-tools
ErrorHandlerService has zero dependency on Express — it's a plain
"map an error to {status, body}" service that works identically
behind any HTTP framework. It had no business living in a package
named express-tools.

Extracted HttpError, ErrorHandlerService, and ErrorHandlingResult
into a new packages/error-tools package (same tsc-build-to-dist
pattern as shared/express-tools). express-tools now only keeps the
actual Express-specific layer: ExpressServer, wrapAsyncHandler, and
createErrorMiddleware (which adapts ErrorHandlerService, imported
from error-tools, onto Express).

- packages/error-tools: new package, depends on shared + zod
- packages/express-tools: drops zod dependency, adds error-tools
  dependency for error-middleware.ts's type import
- apps/api: adds error-tools dependency; app.ts, auth.service.ts,
  require-auth.ts now import HttpError/errorHandlerService from
  error-tools instead of express-tools
- apps/api/Dockerfile: adds COPY for packages/error-tools in the
  runtime stage
- specs/error-handling.md, specs/backend-architecture.md, README.md
  updated to reflect the new package split

Verified: pnpm lint, pnpm build (all packages, correct dependency
order), pnpm test (9/9 Mocha), pnpm test:bdd (5/5 Cucumber), full
Docker rebuild + compose up (no crash-loop), curl + browser checks
of /health, unknown-route 404, signup (201), duplicate-email 409
(code 4001 EMAIL_ALREADY_IN_USE) — all going through the moved
ErrorHandlerService/HttpError correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 18:21:09 +02:00

176 lines
8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gestion des erreurs — Projet Batch-cooking
> Documentation du contrat d'erreurs partagé entre `apps/api` et `apps/web`.
---
## Vue d'ensemble
Quatre pièces travaillent ensemble pour que **toute** erreur, du serveur jusqu'à
l'affichage utilisateur, passe par un chemin unique et prévisible :
- **`packages/shared`** — le contrat : `ErrorCode` (énumération **numérique** de
tous les codes d'erreur métier) et `ApiErrorResponse` (forme JSON de toute
réponse d'erreur de l'API). Ni l'API ni le web ne définissent leur propre liste
de codes, et aucune valeur n'est jamais codée en dur ailleurs (toujours
`ErrorCode.XXX`, jamais un nombre/une chaîne littérale).
- **`packages/error-tools`** — package séparé, **indépendant de tout framework
HTTP** (n'importe pas `express`) : `HttpError`, `ErrorHandlerService`. Le mapping
« erreur → `{ status, body }` » n'a rien de spécifique à Express, donc il ne vit
pas dans `express-tools`.
- **`packages/express-tools`** — package séparé pour l'outillage Express générique
(réutilisable par n'importe quel service Express du monorepo, pas seulement
`apps/api`) : `createErrorMiddleware` (adapte `ErrorHandlerService` à l'API
Express), `ExpressServer`, `wrapAsyncHandler`.
- **`apps/api`** — consomme les deux : lève des `HttpError` (`error-tools`), le
middleware d'erreur final n'est qu'un appel à
`createErrorMiddleware(errorHandlerService)` (`express-tools`).
- **`apps/web` → `ErrorMessageService`** — associe chaque `ErrorCode` à une clé de
traduction, résolue via **i18next** (fichiers de locale sous `src/locales/`).
Les composants n'écrivent jamais de texte d'erreur en dur.
```mermaid
flowchart LR
subgraph ERRTOOLS["packages/error-tools"]
HTTPERR["HttpError"]
EHS["ErrorHandlerService.handle()"]
end
subgraph TOOLS["packages/express-tools"]
MW["createErrorMiddleware()"]
end
subgraph API["apps/api"]
THROW["Route / service<br/>throw new HttpError(status, code, message)"]
THROW --> EHS
MW -->|"app.use(...)"| EHS
end
EHS -->|"JSON: { code, message, details? }"| HTTP["Réponse HTTP"]
subgraph WEB["apps/web"]
CLIENT["ApiClient<br/>lève ApiError(status, code, ...)"]
EMS["ErrorMessageService.getLabel(code)"]
I18N["i18next<br/>locales/fr/translation.json"]
UI["Composant (LoginPage, SignupPage...)"]
CLIENT --> EMS --> I18N --> UI
end
HTTP --> CLIENT
SHARED[("packages/shared<br/>ErrorCode (numérique), ApiErrorResponse")]
SHARED -. contrat .-> THROW
SHARED -. contrat .-> CLIENT
SHARED -. contrat .-> EMS
style SHARED fill:none,stroke:#888,stroke-width:1px
style ERRTOOLS fill:none,stroke:#888,stroke-width:1px
style TOOLS fill:none,stroke:#888,stroke-width:1px
```
---
## Le contrat (`packages/shared/src/errors/error-codes.ts`)
```ts
enum ErrorCode {
VALIDATION_ERROR = 4000,
EMAIL_ALREADY_IN_USE = 4001,
INVALID_CREDENTIALS = 4010,
NOT_AUTHENTICATED = 4011,
NOT_FOUND = 4040,
INTERNAL_ERROR = 5000,
}
interface ApiErrorResponse {
code: ErrorCode;
message: string; // anglais, dev-facing — jamais affiché tel quel côté UI
details?: Record<string, string[] | undefined>; // uniquement pour VALIDATION_ERROR
}
```
**Codes numériques, groupés par famille** (comme les codes HTTP) : `4000``4099`
validation, `4010``4019` authentification, `4040``4049` ressource introuvable,
`5000``5099` interne. Le numéro donne une indication de la catégorie même sans
regarder l'enum.
**Règle** : `message` est destiné aux logs/au débogage (toujours en anglais, jamais
localisé). Le texte affiché à l'utilisateur vient **toujours** de
`ErrorMessageService.getLabel(code)` côté client, jamais de `message` directement.
Et **aucune valeur `ErrorCode` n'est jamais écrite en dur** (ni en nombre, ni en
chaîne) — toujours une référence `ErrorCode.XXX`, y compris dans les tests/mocks.
Pour ajouter un nouveau cas d'erreur :
1. Ajouter le membre dans `ErrorCode`, dans la bonne plage numérique.
2. Le lever via `new HttpError(status, ErrorCode.XXX, "message dev-facing")`.
3. Ajouter sa traduction dans **chaque** fichier `apps/web/src/locales/*/translation.json`, sous `errors.XXX`.
---
## `packages/error-tools` — les pièces liées aux erreurs, indépendantes du framework
- **`http-error.ts`** — `HttpError` : erreur typée portant `status` (code HTTP) et
`code` (`ErrorCode`). C'est ce que lèvent les routes/services au lieu de
construire une réponse HTTP à la main.
- **`error-handler.service.ts`** — `ErrorHandlerService` : un seul point qui sait
transformer n'importe quelle erreur JS (`ZodError`, `HttpError`, n'importe quoi
d'autre) en `{ status, body }`. Le cas générique (`INTERNAL_ERROR`, 500) logue
l'erreur côté serveur sans jamais exposer de détail interne au client.
**N'importe pas `express`** — c'est un service générique, indépendant du
framework HTTP, qui fonctionnerait à l'identique derrière Fastify ou autre. C'est
précisément pour ça qu'il vit dans son propre package plutôt que dans
`express-tools` : rien ici ne dépend d'Express, donc rien ici n'a sa place dans
un package *express*-tools.
Build réel (`tsc` → `dist/`, comme `packages/shared`) : consommé en JS compilé,
pas en TS brut — voir la note dans
[frontend-architecture.md](./frontend-architecture.md#note-sur-les-fichiers-dts)
sur pourquoi ça compte pour un runtime Node pur (Docker).
## `packages/express-tools` — l'adaptateur Express
`packages/express-tools` contient `ExpressServer` (init serveur, enregistrement
de routes/middlewares) et `wrapAsyncHandler` — voir
[backend-architecture.md](./backend-architecture.md) pour le détail complet du
package. La pièce qui concerne spécifiquement les erreurs :
- **`error-middleware.ts`** — `createErrorMiddleware(service: ErrorHandlerService)` :
construit le middleware d'erreur Express (signature à 4 arguments) à partir
d'un `ErrorHandlerService` importé de `@batch-cooking/error-tools` — c'est LUI
la vraie couche Express, `ErrorHandlerService` reste agnostique. `express-tools`
dépend de `error-tools`, jamais l'inverse.
## Côté API (`apps/api`)
- **`app.ts`** — le middleware d'erreur final est enregistré via
`server.setErrorHandler(createErrorMiddleware(errorHandlerService))` (voir
[backend-architecture.md](./backend-architecture.md) pour `ExpressServer`) ;
aucune logique de mapping n'y vit directement, tout est dans `error-tools`.
- Les modules métier (`modules/auth/auth.service.ts`, `middlewares/require-auth.ts`)
importent `HttpError` depuis `@batch-cooking/error-tools` et `ErrorCode` depuis
`@batch-cooking/shared`.
## Côté Web (`apps/web`)
- **`api/client.ts`** — `ApiClient` : lève `ApiError` (porteur de `status`, `code`,
`fieldErrors`) pour toute réponse non-2xx.
- **`services/error-message.service.ts`** — `ErrorMessageService` : convertit le
`ErrorCode` numérique reçu en nom de membre (`ErrorCode[code]`, ex. `4001`
`"EMAIL_ALREADY_IN_USE"`), puis délègue la traduction à **i18next**
(`i18n.t(\`errors.${memberName}\`)`). N'a pas sa propre table de libellés — c'est
i18next + les fichiers de locale qui la portent.
- **`i18n/i18n.ts`** + **`locales/fr/translation.json`** — configuration et
ressources i18next. Ajouter une langue = ajouter une entrée `resources.<lng>`
pointant vers un nouveau fichier de locale, sans toucher un seul composant.
- Les pages (`LoginPage`, `SignupPage`) attrapent `ApiError`, récupèrent `err.code`,
et appellent `errorMessageService.getLabel(err.code)` pour l'afficher — jamais
`err.message`.
## Validation côté formulaire (distincte du contrat d'erreurs API)
Les schémas zod partagés (`packages/shared/src/schemas/auth.ts`) portent leurs
propres messages en français, utilisés pour la validation **avant** l'appel réseau
(retour instantané, aucun aller-retour serveur). C'est un mécanisme séparé du
contrat `ErrorCode`/i18next : ces messages ne quittent jamais le navigateur, et ne
vivent pas dans les fichiers de locale (ils sont dans `packages/shared`, consommé
aussi par l'API qui ne dépend pas d'i18next).