Problem for HTTP API avec Spring

Cette RFC permet d’uniformiser la façon de répondre en cas d’erreur, avec un contenu plus riche que le status code. Le contenu des réponse peut être en JSON ou XML ; je vais me focaliser sur la variante JSON.

RFC

La spécification s’est faite en deux étapes, avec la RFC 7807 puis la RFC 9457.

Elle définit le media type application/problem+json avec les attributs suivants:

  • type (URI): type du problème, référence à une documentation lisible par un humain

  • title (texte): titre court, lisible par un humain, éventuellement traduit

  • status (nombre): status code HTTP

  • detail (texte): description détaillée

  • instance (URI): référence pour l’occurence du problème

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/account/12345/msgs/abc",
}

La spécification est souple pour l’ajout d’attributs supplémentaires.

Intégration dans Spring

Avec Spring Boot 2, l’intégration se faisait via la librairie tierce Zalando Problem. Malheureusement, la compatibilité avec Spring Boot 3 est arrivée avec un peu de retard.

Par contre Spring Boot 3 supporte nativement des fonctionnalités similaires.

Gestion des exceptions

Dans Spring MVC, il y a une gestion par défaut des exceptions, et pour personnaliser on fait des méthodes annotées par @ExceptionHandler dans les classes de contrôleurs ou de façon centralisée dans des classes annotées par @ControllerAdvice.

La gestion par défaut est faire par la classe DefaultHandlerExceptionResolver. Elle renvoie des réponses à un format propre à Spring, construites par le BasicErrorController.

{
  "timestamp": "2024-04-02T07:18:46.445+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/cours"
}

Ce contenu peut être enrichi grâce aux propriétés server.error.*, pour y ajouter les détails de l’exception.

server:
  error:
    include-exception: true
    include-message: always
    include-stacktrace: always
    include-binding-errors: always

La première propriété peut être true ou false. Les trois dernières peuvent avoir les valeurs never, always ou on_param. Avec on_param, le détail n’est retourné que pour les requêtes qui le demandent explicitement.

  • include-exception: classe de l’exception

  • include-message: message de l’exception, paramètre message=true

  • include-stacktrace: stacktrace, paramètre true=true

  • include-binding-errors: erreurs associées, en particulier pour bean validation, paramètre errors=true

{
  "timestamp": "2024-04-02T07:18:46.445+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/cours",
  "exception": "org.springframework.web.bind.MethodArgumentNotValidException",
  "message": "Validation failed for object='cours'. Error count: 1",
  "trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in
            public ResponseEntity<Cours> CoursResource.create(Cours)\n\tat ...",
  "errors": [
    {
      "codes": [
        "NotEmpty.code"
      ],
      "arguments": [
        {
          "codes": [
            "cours.code",
            "code"
          ],
          "arguments": null,
          "defaultMessage": "code",
          "code": "code"
        }
      ],
      "defaultMessage": "must not be empty",
      "objectName": "cours",
      "field": "code",
      "rejectedValue": null,
      "bindingFailure": false,
      "code": "NotEmpty"
    }
  ]
}

ExceptionHandler par défaut

Un premier niveau de gestion d’exceptions est implémenté dans ResponseEntityExceptionHandler, classe abstraite de Spring Framework. Spring Boot l’utilise en auto-configuration associée à la propriété spring.mvc.problemdetails.enabled (classe ProblemDetailsExceptionHandler).

spring:
  mvc:
    problemdetails:
      enabled: true

En activant cette option, une vingtaine d’exceptions sont gérées spécifiquement. Les autres types d’exceptions sont traitées par les resolvers traditionnels, avec le format interne à Spring.

{
    "type": "about:blank",
    "title": "Bad Request",
    "status": 400,
    "detail": "Invalid request content.",
    "instance": "/cours"
}

C’est un début, mais malheureusement ce n’est pas très cohérent. De plus, les propriétés server.error.* ne sont plus prises en charge.

On peut arriver au même résultat en implémentant nous-même la classe abstraite ResponseEntityExceptionHandler.

@ControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {
}

Le résultat est exactement le même qu’avec la propriété spring.mvc.problemdetails.enabled, mais maintenant nous avons un point d’entrée pour enrichir le comportement.

Gestion personnalisée

Réponses personnalisées

On peut ajouter des informations personnalisées en redéfinissant la méthode createResponseEntity(…​), ou handleExceptionInternal(…​). L’idée c’est enrichir le corps de la réponse, qui est probablement de type ProblemDetail.

  • ProblemDetail

    • type: URI

    • title: String

    • status: int

    • detail: String

    • instance: URI

    • properties: Map<String,Object>

On peut ajouter des paramètres personnalisés, voire réactiver le support des propriétés server.error.*.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  ...

  @Override
  protected ResponseEntity<Object> handleExceptionInternal(
        Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
    ResponseEntity<Object> response = super.handleExceptionInternal(ex, body, headers, statusCode, request);
    if (response.getBody() instanceof ProblemDetail problemDetail) {
      addExceptionProperties(problemDetail, ex, request);
    }
    return response;
  }

  private void addExceptionProperties(ProblemDetail problemDetail, Exception exception, WebRequest request) {
    problemDetail.setInstance(URI.create(request.getContextPath()));
    if (errorProperties.isIncludeException()) {
      problemDetail.setProperty("exception", exception.getClass());
    }
    if (errorProperties.getIncludeMessage() == ALWAYS
            || errorProperties.getIncludeMessage() == ON_PARAM
            && getBooleanParameter(request, "message")) {
      problemDetail.setProperty("message", exception.getMessage());
    }
    if (errorProperties.getIncludeStacktrace() == ALWAYS
            || errorProperties.getIncludeStacktrace() == ON_PARAM
            && getBooleanParameter(request, "trace")) {
      problemDetail.setProperty("trace", Arrays.toString(exception.getStackTrace()));
    }
    if (errorProperties.getIncludeBindingErrors() == ALWAYS
            || errorProperties.getIncludeBindingErrors() == ON_PARAM
            && getBooleanParameter(request, "errors")) {
      if (exception instanceof BindingResult bindingResult) {
          problemDetail.setProperty("errors", bindingResult.getAllErrors());
      }
    }
  }

}

Erreurs personnalisées

On peut aussi ajouter des handlers pour nos propres exceptions dans la même classe.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  @ExceptionHandler
  public ProblemDetail handleTechnicalException(TechnicalException exception, WebRequest request) {
    ProblemDetail problem = createProblemDetail(exception, HttpStatus.INTERNAL_SERVER_ERROR, "Technical problem", request);
    addExceptionProperties(problem, exception, request);
    return problem;
  }

  @ExceptionHandler
  public ProblemDetail handleBusinessException(BusinessException exception, WebRequest request) {
    ProblemDetail problem = createProblemDetail(exception, HttpStatus.BAD_REQUEST, "Business problem", request);
    addExceptionProperties(problem, exception, request);
    return problem;
  }

  ...

}

i18n

ErrorResponse sert à ça, avec l’utilisation d’un MessageSource. Pas mal d’exceptions de Spring implémentent ErrorResponse.

  • ErrorResponse

    • getBody(): ProblemDetail

    • getTitleMessageCode(): String

    • getDetailMessageCode(): String

    • getDetailMessageArguments(): String

    • getHeaders(): HttpHeaders

    • getStatusCode(): HttpStatusCode

Au lieu de spécifier un titre et une description détaillées, on passe des codes, du type 'problemDetail.title.unknown-product' et 'problemDetail.detail.unknown-product'. La recherche des vrais messages se fait pas l’intermédiaire d’un MessageSource, avec la locale comme paramètre.

La classe ErrorResponseException implémente cette interface et peut servir de base aux exceptions personnalisées.

Dans ResponseEntityExceptionHandler, les instances de l’interface sont utilisées comme intermédiaire pour construrire une instance de ProblemDetail. Mais une méthode annotée en @ExceptionHandler peut aussi renvoyer directement un objet de type ErrorResponse.

Ça peut faire du code très léger!

  @ExceptionHandler
  public ErrorResponse handleCustomException(CustomException exception) {
	  return exception;
  }

L’instance de MessageSource est enregistrée dans ResponseEntityExceptionHandler.

@RestControllerAdvice
public class CustomExceptionHandler extends ResponseEntityExceptionHandler {

  @PostConstruct
  public void init() {
    ReloadableResourceBundleMessageSource messageSource
          = new ReloadableResourceBundleMessageSource();
    messageSource.setBasename("classpath:problem");
    messageSource.setDefaultEncoding("UTF-8");

    this.setMessageSource(messageSource);
  }

  ...

}

Il ne reste plus qu’à ajouter les messages dans le fichier problem.properties…​

Synthèse

J’avoue ne pas trop apprécier cette architecture. La dualité entre PoblemDetail et ErrorResponse me pose problème. Et le code qui exploite l’héritage de `ResponseEntityExceptionHandler` et personnaliser les réponses est loin d’être limpide.

De ce fait, il vaut peut-être mieux faire un handler autonome et reprendre la gestion de toutes les exceptions.s

Solutions alternatives / complémentaires

Il existe des solutions alternatives sous forme de librairies tierces. Elles sont plus riches que le support natif.

Zalando Problems for Spring

C’est la solution historique, qui était largement utilisée avec Spring Boot 2.

Après quelques hésitation, elle supporte maintenant Spring Boot 3. La migration s’est faite par une adaptation a minima, sans essayer de se caler sur l’architecture intégrée et sans utiliser les classes de Spring.

Spting Boot Problem Handler

Cette librairie a été conçue directement pour Spring Boot 3. Elle n’étend pas ResponseEntityExceptionHandler, mais utilise ProblemDetail.

Je n’ai pas encore testé.