Accueil / Articles

RFC 9457 expliqué : normalisation des réponses d’erreur des API HTTP

RFC 9457 Problem Details fournit aux API HTTP un format d’erreur standard. Il indique ce que signifie chaque champ et comment l’implémenter correctement dans NestJS à l’aide d’un filtre global.

7 min de lecture1562 mots

Presque toutes les API génèrent finalement un en-tête d’erreur qui ressemble à ceci :

{
  "message": "Something went wrong",
  "error": "Bad Request",
  "statusCode": 400
}

Le problème, c’est que cette structure est presque toujours liée au framework qui l’a générée. NestJS, Express, Spring, Rails et d’innombrables autres stacks produisent chacun leur propre version de l’objet d’erreur, avec des noms de champs et des structures différentes.

La RFC 9457, intitulée Problem Details for HTTP APIs, aborde ce problème en proposant une structure normalisée pour les corps d’erreur des API HTTP.

Cette spécification a été publiée en juillet 2023 et remplace la précédente RFC 7807. Son idée de base est simple : au lieu que chaque API conçoive son propre format d’erreur sur mesure, tout le monde peut s’appuyer sur une structure commune lisible par les machines.

Le problème qu’elle résout

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error

Mais savoir que l’on a reçu un 400 ne précise pas la cause réelle de l’échec.

Prenons cette réponse, par exemple :

HTTP/1.1 409 Conflict

Cette seule ligne d’état peut indiquer qu’un nom d’utilisateur est déjà pris, qu’une ressource existe déjà, un désaccord de version, ou encore divers autres conflits.

RFC 9457 résout ce problème en intégrant des détails structurés et descriptifs directement dans le corps de la réponse, permettant ainsi à un client de comprendre ce qui s’est passé sans avoir à analyser la structure d’erreur propre à un framework.

L’objet des détails du problème

Une réponse conforme à cette spécification est un document JSON servi avec ce type de média :

Content-Type: application/problem+json

La spécification définit cinq membres standard :

{
  "type": "https://example.com/problems/username-taken",
  "title": "Username already exists",
  "status": 409,
  "detail": "The username 'ali' is already registered.",
  "instance": "/users"
}

Aucun de ces cinq champs n’est obligatoire, mais chacun remplit une fonction distincte.

type

Celui-ci identifie la categorie de problème qui est signalée.

{
  "type": "https://example.com/problems/username-taken"
}

Pensez-y comme à l’étiquette lisible par la machine pour l’erreur. Idéalement, l’URI indiqué dans type renvoie à une documentation réelle expliquant le problème et la manière dont un client doit y réagir.

Lorsque type n’est pas fourni, on considère qu’il a été défini sur :

about:blank

On s’attend à ce que les clients utilisent cet URI type comme identifiant canonique, plutôt que d’essayer de déduire un sens à partir du texte detail.

title

C’est une étiquette courte et facile à comprendre pour décrire la catégorie de problème.

{
  "title": "Username already exists"
}

Puisque title décrit le type de problème général plutôt qu’un cas spécifique, on s’attend à ce qu’il reste constant dans toutes les occurrences du même erreur.

status

Cela correspond au code d’état HTTP associé à l’erreur.

{
  "status": 409
}

Un point subtil à noter : ce champ est uniquement indicatif.

L’état officiel réel est celui indiqué dans la réponse HTTP elle-même :

HTTP/1.1 409 Conflict

Lorsque le serveur construit le corps de la réponse, la valeur status doit correspondre à ce vrai code d’état.

detail

Ce champ fournit une description lisible par un humain, spécifique à cette occurrence particulière de l’erreur.

{
  "detail": "The username 'ali' is already registered."
}

Ne vous fiez pas à detail pour toute logique automatisée.

En particulier, évitez d’écrire du code client tel que :

if (error.detail.includes("already registered")) {
  // ...
}

Pour tout ce qui doit être vérifié de manière programmée, fiez-vous plutôt à type ou à un membre d’extension personnalisé.

instance

Ce champ fait référence à l’occurrence spécifique du problème signalé.

{
  "instance": "/users"
}

C’est utile lorsque vous devez retracer une panne, aider un utilisateur, corrélater des entrées de journal ou enquêter sur une requête particulière.

Il a un but différent de type:

type      -> what kind of problem is this?
instance  -> which occurrence of that problem is this?

Extensions

La spécification laisse délibérément de la place pour des champs supplémentaires en dehors du ensemble de base.

Par exemple, une réponse d’échec de validation pourrait ressembler à ceci :

{
  "type": "https://example.com/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email address"
    },
    {
      "field": "password",
      "message": "Password is too short"
    }
  ]
}

Ici, errors ne fait pas partie de la spécification RFC 9457 elle-même — c’est un élément d’extension que votre API définit elle-même.

Ce niveau de flexibilité est l’un des plus grands atouts de la spécification : vous disposez d’une structure commune et prévisible tout en pouvant ajouter tous les détails spécifiques à votre domaine dont votre application a besoin.

En-têtes HTTP

Les réponses suivant ce format doivent spécifier :

Content-Type: application/problem+json

Les clients peuvent indiquer leur compatibilité avec ce format en utilisant :

Accept: application/problem+json

Les champs lisibles par l’homme tels que title et detail peuvent également être définis via des en-têtes linguistiques HTTP standard :

Accept-Language: en

le serveur répondant par quelque chose comme :

Content-Language: en

Lorsqu’un client doit attendre avant de réessayer, l’en-tête standard Retry-After peut être utilisé :

Retry-After: 30

À titre d’exemple :

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

Avec un corps tel que :

{
  "type": "https://example.com/problems/rate-limit",
  "title": "Too many requests",
  "status": 429,
  "detail": "Rate limit exceeded."
}

La RFC 9457 ne crée pas de nouveau mécanisme de réessai — elle se contente de réutiliser l’en-tête HTTP existant Retry-After là où cela est pertinent pour le type de problème donné.

Ce que la RFC 9457 ne définit pas

La spécification reste silencieuse sur :

  • les codes d’erreur au niveau de l’application
  • la manière dont vous structurez les erreurs de validation
  • vos conventions d’journalisation
  • la façon dont vos classes d’exception sont organisées
  • la logique d’authentification ou d’autorisation
  • les mécanismes internes permettant à votre API de gérer les exceptions
  • Son champ d’application se limite à vous fournir une structure standard pour décrire les problèmes au niveau HTTP.

    Cela signifie qu’une réponse comme celle-ci est entièrement conforme :

    {
      "type": "https://api.example.com/problems/validation-error",
      "title": "Validation failed",
      "status": 422,
      "detail": "Request validation failed.",
      "code": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "email",
          "message": "Invalid email address"
        }
      ]
    }
    

    Dans cet exemple, code et errors sont des extensions spécifiques à cette API, et non faisant partie de la spécification de base.

    Règles pratiques pour les développeurs

    N’affichez jamais de traces d’exécution ou de messages d’exception bruts dans les réponses destinées à la production.

    Évitez ceci :

    {
      "detail": "QueryFailedError: duplicate key value violates unique constraint..."
    }
    

    Préférez plutôt quelque chose comme :

    {
      "type": "https://api.example.com/problems/conflict",
      "title": "Resource conflict",
      "status": 409,
      "detail": "The username is already in use."
    }
    

    Évitez également de faire en sorte que les clients dépendent du parsing de la chaîne detail.

    N’faites pas cela :

    if (error.detail === "User already exists") {
    }
    

    Préférez vérifier le champ type :

    if (error.type === "https://api.example.com/problems/user-exists") {
    }
    

    Considérez type comme un identifiant stable.

    Un même type doit toujours représenter la même catégorie de problème, bien que les detail puissent naturellement varier d’une occurrence à l’autre.

    Résistez à la tentation d’inventer une nouvelle structure d’erreur pour chaque contrôleur ou point de terminaison. Adoptez un format uniforme pour les détails des problèmes dans toute votre API, et n’ajoutez des champs personnalisés que lorsque c’est vraiment nécessaire.

    Enfin, ne confondez pas une réponse contenant des détails de problème avec un en-tête de réponse normal indiquant un succès. Ce format existe spécifiquement pour décrire les échecs, et il doit être associé aux codes d’état 4xx et 5xx.

    Mise en œuvre de RFC 9457 dans NestJS

    Une manière propre de gérer cela dans NestJS consiste à centraliser la logique au sein d’un filtre d’exception global.

    import {
      ArgumentsHost,
      Catch,
      ExceptionFilter,
      HttpException,
      HttpStatus,
    } from '@nestjs/common';
    import type { Request, Response } from 'express';
    
    
    interface ProblemDetails {
      type?: string;
      title?: string;
      status?: number;
      detail?: string;
      instance?: string;
      [key: string]: unknown;
    }
    
    @Catch()
    export class ProblemDetailsExceptionFilter implements ExceptionFilter {
      catch(exception: unknown, host: ArgumentsHost): void {
    
        const ctx = host.switchToHttp();
    
        const request = ctx.getRequest<Request>();
        const response = ctx.getResponse<Response>();
    
        let status = HttpStatus.INTERNAL_SERVER_ERROR;
        let detail = 'An unexpected error occurred.';
        let extensions: Record<string, unknown> = {};
    
        if (exception instanceof HttpException) {
          status = exception.getStatus();
          const errorResponse = exception.getResponse();
    
          if (typeof errorResponse === 'string') {
            detail = errorResponse;
          } else if (typeof errorResponse === 'object' && errorResponse !== null) {
            const body = errorResponse as Record<string, unknown>;
            if (typeof body.message === 'string') {
              detail = body.message;
            }
            const {
              statusCode: _statusCode,
              error: _error,
              message: _message,
              ...rest
            } = body;
            extensions = rest;
          }
        }
    
        const problem: ProblemDetails = {
          type: 'about:blank',
          title: HttpStatus[status] ?? 'HTTP Error',
          status,
          detail,
          instance: request.originalUrl,
          ...extensions,
        };
    
        response
          .status(status)
          .type('application/problem+json')
          .json(problem);
      }
    }
    

    Puis connectez-le à l’application dans son ensemble :

    app.useGlobalFilters(
      new ProblemDetailsExceptionFilter(),
    );
    

    Plutôt que de renvoyer la structure d’erreur par défaut de NestJS, comme :

    {
      "statusCode": 404,
      "message": "User not found",
      "error": "Not Found"
    }
    

    vos points d’entrée émettront systématiquement :

    HTTP/1.1 404 Not Found
    Content-Type: application/problem+json
    
    {
      "type": "about:blank",
      "title": "Not Found",
      "status": 404,
      "detail": "User not found",
      "instance": "/users/42"
    }
    

    À partir de là, vous pouvez définir vos propres URI de type stables pour des cas d’échec spécifiques :

    https://api.example.com/problems/user-not-found
    https://api.example.com/problems/validation-error
    https://api.example.com/problems/username-taken
    https://api.example.com/problems/rate-limit
    

    Cette configuration confère à votre API un contrat d’erreur prévisible et uniforme, tandis que les exceptions intégrées de NestJS ainsi que vos erreurs d’application personnalisées continuent de fonctionner en arrière-plan.

    Lectures complémentaires

  • NestJS vs Node.js : Pourquoi la structure l’emporte sur la liberté à grande échelle — Cet article explique comment NestJS superpose une architecture, une injection de dépendances et des conventions à Node.js afin de résoudre les problèmes de maintenabilité que Node ou Express purs ne peuvent pas gérer.
  • Zod vs express-validator : Deux approches pour la validation dans Express — Il compare la validation des requêtes basée sur des schémas avec Zod à celle utilisant les middleware chainés d’express-validator, en abordant la configuration, le formatage des erreurs et les pièges courants.