Gestion des exceptions
Introduction
Lorsqu'une application ASP.NET Core traite une requête HTTP, différentes erreurs peuvent survenir : une ressource demandée n'existe pas, les données fournies sont invalides, une règle métier n'est pas respectée, une erreur technique inattendue se produit, etc.
Une première approche consiste à intercepter ces erreurs directement dans les actions des contrôleurs avec des blocs try/catch. Il est possible de factoriser une partie de cette gestion à l’aide de classes de base et de délégués. Cependant, ASP.NET Core propose une solution transversale à l'ensemble d'une application, permettant aussi de la centraliser. L'interface IExceptionHandler, associée à l'utilisation de la classe ProblemDetails, permet notamment d'intercepter les exceptions et de retourner aux clients de l'API des réponses HTTP homogènes.
Dans cet article, nous étudierons comment implémenter cette gestion globale des erreurs. Je tiens à préciser un point : cette gestion des exceptions ne traite pas des erreurs de validation des paramètres des endpoints, qui feront l’objet d’un prochain article dans cette newsletter.
Problématique
Nous développons un service Web API permettant de récupérer un client à partir de son identifiant :
[ApiController]
[Route("api/customers")]
public class CustomerController(
ICustomerService customerService,
ILogger<CustomerController> logger) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var customer = await customerService.GetByIdAsync(id);
if (customer is null)
return NotFound();
return Ok(customer);
}
catch (Exception ex)
{
logger.LogError(ex, "Une erreur est survenue lors de la récupération du client");
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
}
Ce code fonctionne, mais l'action assume plusieurs responsabilités :
- Traiter la requête HTTP.
- Appeler le service métier.
- Déterminer la réponse à retourner lorsque le client n'existe pas.
- Intercepter et journaliser les erreurs.
- Construire la réponse HTTP qui sera renvoyée.
L'objectif de cet article est de montrer comment gérer les erreurs de manière globale à l'application. Toutes les erreurs ne correspondent pas à une erreur interne du serveur. Par exemple, lorsqu'un client demandé n'existe pas, nous pouvons représenter cette situation avec une exception spécifique :
public class CustomerNotFoundException(int customerId)
: Exception($"Le client dont l'identifiant est {customerId} n'existe pas.")
{
public int CustomerId { get; } = customerId;
}
Notre service peut alors lever cette exception :
public async Task<CustomerDto> GetByIdAsync(int id)
{
return await dbContext.Customers
.Where(c => c.Id == id)
.Select(c => new CustomerDto(
c.Id,
c.FirstName,
c.LastName))
.FirstOrDefaultAsync()
?? throw new CustomerNotFoundException(id);
}
Le service indique ainsi qu'il lui est impossible de trouver le client demandé à partir d'un identifiant inconnu. Il n'a cependant pas besoin de savoir que cette situation doit produire une réponse HTTP 404 (Not Found). Cette décision appartient à la couche chargée de communiquer l'erreur au client de l'application Web.
L'interface IExceptionHandler
ASP.NET Core fournit une interface nommée IExceptionHandler pour centraliser le traitement des exceptions. Elle propose une méthode nommée TryHandleAsync, qui prend en paramètre le contexte HTTP associé à la requête, l'exception et un jeton d'annulation :
ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken);
La valeur booléenne retournée indique si l'exception a été traitée. Implémentons un premier gestionnaire d'exception global :
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(
exception,
"Une erreur non gérée est survenue");
httpContext.Response.StatusCode =
StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(
new
{
Status = StatusCodes.Status500InternalServerError,
Message = "Une erreur interne est survenue."
},
cancellationToken);
return true;
}
}
Toutes les exceptions peuvent être prises en charge par ce gestionnaire d'exception. Cependant, ASP.NET Core nous permet d'utiliser un format standardisé avec l'utilisation de la classe ProblemDetails, ce qui nous évite de définir notre propre structure de réponse.
Standardiser les réponses avec la classe ProblemDetails
Présentation de la classe ProblemDetails
La classe ProblemDetails permet de représenter les détails d'une erreur retournée par une API HTTP. Une réponse peut par exemple avoir la structure suivante :
{
"type": "https://algowin.fr/errors/customer-not-found",
"title": "Client introuvable",
"status": 404,
"detail": "Le client dont l'identifiant est 42 n'existe pas.",
"instance": "/api/customers/42"
}
Les principales propriétés sont :
- type : URI qui identifie la catégorie ou le type de problème.
- title : titre synthétique.
- status : code HTTP.
- detail : informations supplémentaires sur l'erreur.
- instance : URI qui identifie l’occurrence précise du problème, (path de la requête).
L'utilisation de cette structure permet aux différents endpoints des services Web de retourner des erreurs de manière homogène.
Utiliser ProblemDetails dans notre gestionnaire
Modifions l'implémentation de notre gestionnaire afin qu'il retourne une instance de la classe ProblemDetails :
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Une erreur non gérée est survenue");
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Erreur interne",
Detail = "Une erreur interne est survenue.",
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(
problemDetails,
cancellationToken);
return true;
}
}
Nous disposons maintenant d'un traitement centralisé des exceptions, mais elles produisent encore une réponse HTTP 500 (Internal Server Error). Or une exception de type CustomerNotFoundException pourrait produire une réponse HTTP 404 (Not Found), car la ressource demandée n'a pas été trouvée.
L’absence d’une ressource peut être considérée comme un résultat attendu, notamment pour une opération de lecture, et être représentée par une valeur null ou un type de résultat. Lever une exception métier est davantage adapté lorsque cette absence empêche l’exécution d'un service.
Adapter la réponse HTTP au type d'exception
Nous pouvons déterminer les informations retournées en fonction du type de l'exception :
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = exception switch
{
CustomerNotFoundException => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Client introuvable",
Detail = exception.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Erreur interne",
Detail = "Une erreur interne est survenue."
}
};
if (problemDetails.Status ==
StatusCodes.Status500InternalServerError)
{
logger.LogError(
exception,
"Une erreur non gérée est survenue");
}
problemDetails.Instance = httpContext.Request.Path;
httpContext.Response.StatusCode = problemDetails.Status!.Value;
await httpContext.Response.WriteAsJsonAsync(
problemDetails,
cancellationToken);
return true;
}
}
Nous pouvons appliquer le même principe à différents types d'exceptions :
var problemDetails = exception switch
{
CustomerNotFoundException => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Client introuvable",
Detail = exception.Message
},
BusinessRuleException => new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Règle métier non respectée",
Detail = exception.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Erreur interne",
Detail = "Une erreur interne est survenue."
}
};
Le gestionnaire joue ainsi le rôle de traducteur entre les exceptions de l'application et la réponse HTTP retournée. Une exception communique des informations sur ce qui s'est passé ; le gestionnaire détermine comment cette erreur doit être communiquée au client HTTP.
Améliorer la réponse avec IProblemDetailsService
L’interface IProblemDetailsService est un service ASP.NET Core chargé de produire une réponse HTTP à partir d’un objet ProblemDetails. Il évite de sérialiser manuellement cet objet avec la méthode WriteAsJsonAsync, applique la configuration définie avec AddProblemDetails et sélectionne un format de réponse compatible avec les en-têtes HTTP de la requête. Dans notre gestionnaire d'exception centralisé, nous lui transmettons le contexte HTTP et l’objet à retourner :
public class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = exception switch
{
CustomerNotFoundException => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Client introuvable",
Detail = exception.Message
},
BusinessRuleException => new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Règle métier non respectée",
Detail = exception.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Erreur interne",
Detail = "Une erreur interne est survenue."
}
};
if (problemDetails.Status == StatusCodes.Status500InternalServerError)
{
logger.LogError(
exception,
"Une erreur non gérée est survenue lors du traitement de {Path}",
httpContext.Request.Path);
}
problemDetails.Instance = httpContext.Request.Path;
httpContext.Response.StatusCode = problemDetails.Status!.Value;
return await problemDetailsService.TryWriteAsync(
new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
}
Enregistrer le gestionnaire
Le gestionnaire doit être enregistré dans le conteneur d'injection de dépendances :
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
ASP.NET Core enregistre notre handler de gestion des exceptions avec une durée de vie singleton : une seule instance est créée pour toute la durée d’exécution de l’application. C'est pourquoi, il ne faut pas injecter dans notre handler un service scoped, tel qu’un DbContext (EF Core) ou un service métier dépendant d’un DbContext.
Puis nous activons la gestion des exceptions dans la pipeline HTTP via la méthode UseExceptionHandler, qui ajoute le middleware chargé d'intercepter les exceptions non gérées lors du traitement des requêtes :
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Simplifier l'implémentation des actions des contrôleurs
Reprenons l'implémentation de notre contrôleur dont l'implémentation initiale est :
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
try
{
var customer = await customerService.GetByIdAsync(id);
if (customer is null)
return NotFound();
return Ok(customer);
}
catch (Exception ex)
{
logger.LogError(ex, "Une erreur est survenue");
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
Grâce à l'utilisation de notre gestion globale des exceptions, l'action GetById peut se contenter d'utiliser un service métier répondant à son besoin, sans se soucier de la gestion des erreurs :
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var customer = await customerService.GetByIdAsync(id);
return Ok(customer);
}
Si le client n'est pas trouvé à partir de l'identifiant communiqué, l'exception de type CustomerNotFoundException permettra de remonter jusqu'au gestionnaire qui retourne une réponse HTTP 404. Si une exception inattendue survient, le gestionnaire la journalise et retournera une réponse HTTP 500. Le contrôleur n'a donc plus besoin de connaître la manière dont les exceptions sont transformées en réponses HTTP.
Ne pas exposer les détails des erreurs techniques
Lorsqu'une exception inattendue survient, il peut être tentant de retourner directement son message :
Il faut prêter attention à cette pratique, car une exception peut contenir des informations sensibles que nous ne souhaitons pas communiquer. Il est préférable de retourner :
et enregistrer les informations techniques dans les logs de l'application :
logger.LogError(
exception,
"Une erreur non gérée est survenue lors du traitement de {Path}",
httpContext.Request.Path);
Le client obtient ainsi une réponse sans avoir accès aux détails internes de l'erreur.