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
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ètremessage=true
-
include-stacktrace
: stacktrace, paramètretrue=true
-
include-binding-errors
: erreurs associées, en particulier pour bean validation, paramètreerrors=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é. |