Gestion des Exceptions
Guide complet pour gérer les erreurs proprement dans votre application Micronaut avec GlobalExceptionHandler.
Une bonne gestion des erreurs améliore l’expérience utilisateur et facilite le débogage. Ce guide vous montre comment créer un système d’exceptions cohérent.
Pourquoi un GlobalExceptionHandler ?
Un GlobalExceptionHandler centralise la gestion des erreurs pour retourner des réponses HTTP cohérentes.
Problème
Sans GlobalExceptionHandler
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
try {
return getTrendsUseCase.execute(keyword)
.map(TrendResponseDto::fromDomain)
.orElse(null); // ❌ Retourne null au lieu de 404
} catch (Exception e) {
// ❌ Erreur non gérée, retourne 500 avec stack trace exposée
e.printStackTrace();
return null;
}
}Problèmes :
- Code répétitif dans chaque controller
- Pas de format standard pour les erreurs
- Stack traces exposées au client
- Codes HTTP incorrects
Architecture de Gestion des Erreurs
Flow Diagram
Flux de Gestion des Erreurs
┌─────────────────────────────────┐
│ TrendController │
│ @Get │
│ getTrends( │
│ @NotBlank String keyword │
│ ) { │
│ throw ResourceNotFound(...); │
│ } │
└─────────┬────────────┬──────────┘
│ │
│ Validation │ ResourceNotFoundException
│ échoue │
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ Micronaut │ │ GlobalException │
│ Handler │ │ Handler (custom) │
│ (built-in) │ │ │
│ │ │ Intercepte: │
│ Gère: │ │ - ResourceNotFound │
│ - @NotBlank │ │ - Exception │
│ - @Pattern │ │ │
│ - @Size │ │ Retourne: │
│ │ │ - ErrorResponse │
└──────┬───────┘ └─────────┬──────────┘
│ │
└────────┬───────────┘
▼
┌────────────────┐
│ Client │
│ Reçoit JSON │
│ + HTTP Status │
└────────────────┘Créer le GlobalExceptionHandler
Créer l’ErrorResponse DTO
Structure standardisée pour toutes les erreurs.
// application/port/input/dto/ErrorResponse.java
package org.smoka.application.port.input.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.http.HttpStatus;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private int status; // 400, 404, 500, etc.
private String error; // "Bad Request", "Not Found"
private String message; // Message utilisateur
private LocalDateTime timestamp;
private String path; // Endpoint appelé
private Object details; // Infos supplémentaires (optionnel)
// Factory method simple
public static ErrorResponse of(
HttpStatus httpStatus,
String message,
String path
) {
return ErrorResponse.builder()
.status(httpStatus.getCode())
.error(httpStatus.getReason())
.message(message)
.timestamp(LocalDateTime.now())
.path(path)
.build();
}
// Factory method avec détails
public static ErrorResponse of(
HttpStatus httpStatus,
String message,
String path,
Object details
) {
return ErrorResponse.builder()
.status(httpStatus.getCode())
.error(httpStatus.getReason())
.message(message)
.timestamp(LocalDateTime.now())
.path(path)
.details(details)
.build();
}
}Créer ResourceNotFoundException
Exception custom pour les ressources introuvables (404).
// application/exception/ResourceNotFoundException.java
package org.smoka.application.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String resourceName, Object id) {
super(String.format("%s with id '%s' not found", resourceName, id));
}
}Créer le GlobalExceptionHandler
// infrastructure/exception/GlobalExceptionHandler.java
package org.smoka.infrastructure.exception;
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.Produces;
import io.micronaut.http.server.exceptions.ExceptionHandler;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import org.smoka.application.exception.ResourceNotFoundException;
import org.smoka.application.port.input.dto.ErrorResponse;
@Slf4j
@Singleton
@Produces
@Requires(classes = {Exception.class, ExceptionHandler.class})
public class GlobalExceptionHandler implements ExceptionHandler<Exception, HttpResponse<ErrorResponse>> {
@Override
public HttpResponse<ErrorResponse> handle(HttpRequest request, Exception exception) {
// 1. ResourceNotFoundException → 404
if (exception instanceof ResourceNotFoundException) {
return handleResourceNotFound(request, (ResourceNotFoundException) exception);
}
// 2. Toutes autres exceptions → 500
return handleGenericException(request, exception);
}
private HttpResponse<ErrorResponse> handleResourceNotFound(
HttpRequest request,
ResourceNotFoundException exception
) {
log.info("Resource not found: {}", exception.getMessage());
ErrorResponse error = ErrorResponse.of(
HttpStatus.NOT_FOUND,
exception.getMessage(),
request.getPath()
);
return HttpResponse.notFound(error);
}
private HttpResponse<ErrorResponse> handleGenericException(
HttpRequest request,
Exception exception
) {
log.error("Unhandled exception for {} {}", request.getMethod(), request.getPath(), exception);
ErrorResponse error = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. Please contact support if the problem persists.",
request.getPath()
);
return HttpResponse.serverError(error);
}
}Utiliser dans un Controller
@Get
public TrendResponseDto getTrends(
@QueryValue @NotBlank String keyword,
@QueryValue(defaultValue = "US") String region
) {
return getTrendsUseCase.execute(keyword, region)
.map(TrendResponseDto::fromDomain)
.orElseThrow(() -> new ResourceNotFoundException(
String.format("No trends found for keyword '%s' in region '%s'", keyword, region)
));
}Types d’Erreurs Gérées
404 - Not Found
404 - ResourceNotFoundException
Quand l’utiliser : Ressource demandée n’existe pas.
Code :
@Get("/{id}")
public TrendResponseDto getTrendById(@PathVariable Long id) {
return repository.findById(id)
.map(TrendResponseDto::fromDomain)
.orElseThrow(() -> new ResourceNotFoundException("Trend", id));
}Requête :
GET /api/trends/999Réponse :
{
"status": 404,
"error": "Not Found",
"message": "Trend with id '999' not found",
"timestamp": "2025-10-18T16:30:00",
"path": "/api/trends/999"
}Utilisez Optional.orElseThrow() pour transformer un Optional vide en 404 automatiquement.
Créer une Exception Custom
Créer la Classe d’Exception
// application/exception/TrendLimitExceededException.java
package org.smoka.application.exception;
public class TrendLimitExceededException extends RuntimeException {
public TrendLimitExceededException(int limit) {
super(String.format(
"You have exceeded the limit of %d trend requests per minute",
limit
));
}
}Ajouter le Handler
@Override
public HttpResponse<ErrorResponse> handle(HttpRequest request, Exception exception) {
if (exception instanceof ResourceNotFoundException) {
return handleResourceNotFound(request, (ResourceNotFoundException) exception);
}
// ✅ Nouveau handler
if (exception instanceof TrendLimitExceededException) {
return handleTrendLimitExceeded(request, (TrendLimitExceededException) exception);
}
return handleGenericException(request, exception);
}
private HttpResponse<ErrorResponse> handleTrendLimitExceeded(
HttpRequest request,
TrendLimitExceededException exception
) {
log.warn("Rate limit exceeded for {}", request.getRemoteAddress());
ErrorResponse error = ErrorResponse.of(
HttpStatus.TOO_MANY_REQUESTS, // 429
exception.getMessage(),
request.getPath()
);
return HttpResponse.status(HttpStatus.TOO_MANY_REQUESTS).body(error);
}Utiliser dans le Code
@Get
public TrendResponseDto getTrends(@QueryValue String keyword) {
if (rateLimiter.isLimitExceeded(request.getClientAddress())) {
throw new TrendLimitExceededException(100);
}
// Logique normale
}Résultat :
{
"status": 429,
"error": "Too Many Requests",
"message": "You have exceeded the limit of 100 trend requests per minute",
"timestamp": "2025-10-18T16:30:00",
"path": "/api/trends"
}Mapping Exception → Code HTTP
Voici comment chaque exception est mappée à un code HTTP.
| Exception | Code HTTP | Géré par | Quand l’utiliser |
|---|---|---|---|
| ResourceNotFoundException | 404 | GlobalExceptionHandler | Ressource n’existe pas |
| @NotBlank, @Pattern, @Size | 400 | Micronaut (auto) | Validation paramètres |
| TrendLimitExceededException | 429 | GlobalExceptionHandler | Rate limiting |
| IllegalArgumentException | 400 | GlobalExceptionHandler | Argument invalide |
| Exception (toutes autres) | 500 | GlobalExceptionHandler | Erreur inattendue |
Règle : Utilisez des exceptions custom pour les erreurs métier. Laissez Micronaut gérer les validations techniques.
Tests (Piège Important)
Piège : Vous ne pouvez PAS tester les erreurs de validation Micronaut car il a ses propres handlers plus spécifiques !
❌ Ce qui ne marche PAS
Tests qui NE marcheront JAMAIS
@Test
void shouldReturn400WhenKeywordIsMissing() {
// ❌ CE TEST NE MARCHERA JAMAIS
// Micronaut gère les @QueryValue manquants AVANT ton handler
// Ton GlobalExceptionHandler n'est JAMAIS appelé !
HttpClientResponseException ex = assertThrows(
HttpClientResponseException.class,
() -> client.toBlocking().exchange(
HttpRequest.GET("/api/trends")
)
);
// ❌ Ton ErrorResponse ne sera jamais retourné
// C'est le format Micronaut qui est retourné
}Pourquoi ? Micronaut a une hiérarchie de handlers :
- Handlers spécifiques Micronaut → priorité haute
- Ton GlobalExceptionHandler → priorité basse
Ce que vous pouvez tester
| Type d’erreur | Géré par | Handler appelé ? | Testable ? |
|---|---|---|---|
@QueryValue manquant | Micronaut | ❌ Non | ❌ Non |
@Valid échoue | Micronaut | ❌ Non | ❌ Non |
| JSON mal formé | Micronaut | ❌ Non | ❌ Non |
| ResourceNotFoundException | Votre handler | ✅ Oui | ✅ Oui |
| Custom exceptions | Votre handler | ✅ Oui | ✅ Oui |
| RuntimeException | Votre handler | ✅ Oui | ✅ Oui |
Bonnes Pratiques
✅ À Faire
À Faire
1. Utiliser des exceptions custom pour la logique métier
throw new ResourceNotFoundException("Trend", id);2. Logger les erreurs serveur (500)
log.error("Unexpected error", exception);3. Masquer les détails techniques en production
String message = "An unexpected error occurred. Please contact support if the problem persists.";4. Fournir des messages clairs
"Keyword must be at least 2 characters long" // ✅ Clair
"Invalid input" // ❌ Trop vague5. Tester tous les cas d’erreur
@Test
void shouldReturn404WhenResourceNotFound() { }
@Test
void shouldReturn500WhenUnexpectedError() { }6. Utiliser les annotations de validation Micronaut
@NotBlank(message = "Keyword cannot be empty")
@Pattern(regexp = "...", message = "Keyword must contain...")Structure Finale
- ResourceNotFoundException.java
- TrendLimitExceededException.java
- GlobalExceptionHandler.java
Checklist
Avant de considérer votre gestion d’exceptions complète :
-
ErrorResponseDTO créé avec factory methods -
ResourceNotFoundExceptioncréée et utilisée -
GlobalExceptionHandlercréé et gère 404 + 500 - Validation avec
@NotBlank,@Pattern,@Sizeajoutée aux controllers - Logs configurés (DEBUG, INFO, ERROR)
- Messages d’erreur clairs et informatifs
- Stack traces masquées en production
- Tests des exceptions custom (avec controller de test)
- Documentation des codes HTTP retournés
Résumé
Un GlobalExceptionHandler bien configuré améliore la sécurité, la maintenabilité et l’expérience utilisateur de votre API.
| Type d’Erreur | Code HTTP | Géré par | Utilisation |
|---|---|---|---|
| Validation | 400 | Micronaut | @NotBlank, @Pattern, @Size |
| ResourceNotFoundException | 404 | GlobalExceptionHandler | Ressource introuvable |
| TrendLimitExceededException | 429 | GlobalExceptionHandler | Rate limiting |
| Exception (autres) | 500 | GlobalExceptionHandler | Erreurs inattendues |
Règle d’or : Utilisez des exceptions custom pour les erreurs métier. Laissez Micronaut gérer les validations simples. Masquez toujours les détails techniques en production.
Prochaines étapes
Maintenant que vous maîtrisez la gestion des exceptions, explorez l’utilisation avancée de ObjectMapper pour les données JSON complexes.