Aller au contenu

ASP.NET • Gestion d'exception

Une exception est levée pour une erreur non gérée, une opération interdite ou tout traitement non prévu. En phase de développement, afficher toutes les exceptions est très pratique. Une exception fournit beaucoup d’informations nous permettant d’identifier le code l’ayant levée. Cela nous aide justement à fiabiliser notre code en gérant les potentielles erreurs, en évitant les opérations interdites, etc. Bref en améliorant la robustesse de notre code. Car une exception doit rester… exceptionnelle.

Cependant, lorsque le site web est déployé en production, il est absolument nécessaire de filtrer toutes les exceptions. C’est important pour des raisons de sécurité : une exception contient énormément d’informations sensibles. Elle dévoile potentiellement : les technologies employées, l’emplacement des fichiers de code, le nom du serveur de base de données, etc.

Nous allons voir ensemble deux façons de mettre en place les filtres nécessaires pour n’exposer aucune information sensible au cas où, malgré tous nos efforts, une exception est levée.

Commençons par créer un nouveau projet de type webapi. Dans un terminal, tapons la commande suivante :

Fenêtre de terminal
dotnet new webapi --name=ExceptionFilterWebApi

Ouvrons le projet ExceptionFilterWebApi dans notre IDE favori.

Voici la structure du projet :

  • RépertoireExceptionFilterWebApi
    • RépertoireProperties
      • launchSettings.json
    • appsettings.json
    • appsettings.Development.json
    • ExceptionFilterWebApi.http
    • Program.cs

Swagger UI n’est plus disponible par défaut depuis .NET 9. Nous allons installer un package NuGet nous permettant d’avoir une page de test de notre Web API : Scalar.AspNetCore.

Nous pouvons utiliser l’IDE pour ajouter ce package, ou bien passer par un terminal :

Fenêtre de terminal
dotnet add package Scalar.AspNetCore

Nous pouvons maintenant mettre à jour Program.cs :

Program.cs
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Mise à jour des paramètres de démarrage du projet

Section intitulée « Mise à jour des paramètres de démarrage du projet »

Afin de nous faciliter la vie, nous allons éditer le fichier de paramétrage de lancement du projet :

  • activer l’ouverture automatique du navigateur ;
  • ouvrir l’URL scalar/v1 par défaut.
Properties/launchSettings.json
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchBrowser": true,
"launchUrl": "scalar/v1",
"applicationUrl": "https://localhost:7200;http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

Si nous exécutons le projet, une nouvelle page (ou un nouvel onglet) s’ouvre dans notre navigateur par défaut. L’URL de la page est :https://localhost:7200/scalar/v1 et la page Scalar est affichée.

Dans le menu de gauche, nous pouvons cliquer sur /weatherforecast GET. Sur la droite, dans la section /weatherforecast, un clic sur le bouton ▶ Test Request ouvre une popin. Si nous appuyons sur le bouton ▶ Send alors :

  • Le code statut est : 200 OK ;
  • Le body est de type : application/json ;
  • Le body contient le JSON :
[
{
"date": "2024-11-19",
"temperatureC": -17,
"summary": "Freezing",
"temperatureF": 2
},
{
"date": "2024-11-20",
"temperatureC": 43,
"summary": "Sweltering",
"temperatureF": 109
},
{
"date": "2024-11-21",
"temperatureC": 44,
"summary": "Balmy",
"temperatureF": 111
},
{
"date": "2024-11-22",
"temperatureC": -8,
"summary": "Chilly",
"temperatureF": 18
},
{
"date": "2024-11-23",
"temperatureC": 41,
"summary": "Sweltering",
"temperatureF": 105
}
]

Maintenant que notre Web API est opérationnelle, nous allons faire en sorte qu’une exception soit levée.

Program.cs
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
throw new Exception("Weather forecast");
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Si nous relançons le projet, l’appel de /weatherforecast dans Scalar retourne :

  • Le code statut est : 500 Internal Server Error ;
  • Le body est de type : text/plain ;
  • Le body ne contient plus les données précédentes.

De nombreuses informations sont présentes maintenant :

  • les détails de l’exception ;
  • la ligne de code fautive ;
  • la pile d’appels ;
  • les paramètres de query ;
  • les données d’en-tête ;
  • etc.

Nous sommes prêts pour mettre en place la gestion des exceptions dans notre projet.

Les middlewares sont un élément important d’une application ASP.NET. Ils sont utilisés dans la gestion des requêtes et des réponses. L’ensemble des middlewares constitue un pipeline que chaque requête traverse. Chaque middleware peut :

  • bloquer ou laisser passer la requête au prochain middleware ;
  • analyser et modifier la requête (et la réponse associée).

Créons le dossier Middlewares, puis le fichier ExceptionHandlingMiddleware.cs sous ce dossier.

  • RépertoireExceptionFilterWebApi
    • RépertoireProperties
      • launchSettings.json
    • RépertoireMiddlewares
      • ExceptionHandlingMiddleware.cs
    • appsettings.json
    • appsettings.Development.json
    • ExceptionFilterWebApi.http
    • Program.cs
Middlewares/ExceptionHandlingMiddleware.cs
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Middlewares;
internal sealed class ExceptionHandlingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception)
{
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error"
};
context.Response.StatusCode = problemDetails.Status.Value;
await context.Response.WriteAsJsonAsync(problemDetails);
}
}
}

La classe ExceptionHandlingMiddleware :

  • injecte le gestionnaire de requête RequestDelegate ;
  • entoure d’un try + catch le traitement de la requête await next(context); ;
  • si le traitement de la requête fonctionne normalement, alors la requête passe au middleware suivant ;
  • si le traitement de la requête lève n’importe quelle exception, alors celle-ci est interceptée.

Dans ce cas, un ProblemDetails est créé et associé :

  • au code HTTP 500 (Internal Server Error) ;
  • à un titre générique "Erreur serveur".

La réponse est générée :

  • avec le code statut HTTP 500 (Internal Server Error) ;
  • avec un body contenant le ProblemDetails sérialisé en JSON.

Nous devons maintenant ajouter ce middleware dans le pipeline afin de l’activer.

Program.cs
using ExceptionFilterWebApi.Middlewares;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.MapGet("/weatherforecast", () =>
{
throw new Exception("Weather forecast");
})
.WithName("GetWeatherForecast");
app.Run();

Nous pouvons maintenant relancer notre API Web. Depuis Scalar, l’appel vers /weatherforecast retourne :

  • Le code statut est : 500 Internal Server Error ;
  • Le body est de type : application/json ;
  • Le body contient le JSON :
{
"title": "Server Error",
"status": 500
}

Ce JSON correspond à ce que nous avons paramétré dans le middleware ExceptionHandlingMiddleware. Aucune information sensible n’est exposée. Bien entendu, nous pouvons personnaliser ce message selon nos besoins.

Notre middleware, comme tous les middlewares, est invoqué à chaque requête. L’ordre des middlewares est important. Le nôtre doit être positionné dans le haut de la pile afin d’intercepter au plus tôt les éventuelles exceptions.

Nous allons maintenant appliquer une autre solution pour intercepter les exceptions : IExceptionHandler. Cette solution est disponible depuis .NET 8. Elle fonctionne comme un middleware spécialisé dans la gestion des exceptions.

Créons le dossier Handlers, puis le fichier GlobalExceptionsHandler.cs sous ce dossier.

  • RépertoireExceptionFilterWebApi
    • RépertoireProperties
      • launchSettings.json
    • RépertoireHandlers
      • GlobalExceptionsHandler.cs
    • RépertoireMiddlewares
      • ExceptionHandlingMiddleware.cs
    • appsettings.json
    • appsettings.Development.json
    • ExceptionFilterWebApi.http
    • Program.cs
Handlers/GlobalExceptionHandler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class GlobalExceptionsHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error"
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}

L’interface IExceptionHandler a une seule méthode TryHandleAsync() qui est invoquée si une exception est levée.

Cette méthode asynchrone a pour paramètres :

  • le contexte HTTP (comme pour le middleware) ;
  • l’exception levée.

Elle retourne un booléen indiquant si l’exception a été traitée ou pas. Cela nous permet d’avoir plusieurs handlers d’exception.

Le code de la méthode TryHandleAsync() est similaire à la méthode InvokeAsync du handler ExceptionHandlingMiddleware :

  • créé un ProblemDetails ;
  • met à jour de la réponse ;
  • retourne true adin d’indiquer que l’exception a été gérée.

Nous pouvons maintenant mettre à jour Program.cs afin de déclarer et d’activer notre gestionnaire d’exceptions.

Program.cs
using ExceptionFilterWebApi.Middlewares;
using ExceptionFilterWebApi.Handlers;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Register exceptions handlers
builder.Services.AddExceptionHandler<GlobalExceptionsHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseExceptionHandler();
app.MapGet("/weatherforecast", () =>
{
throw new Exception("Weather forecast");
})
.WithName("GetWeatherForecast");
app.Run();

Voici les modifications que nous avons apportées :

  • ajout du service GlobalExceptionsHandler dans le conteneur de dépendances ;
  • ajout du service IProblemDetailsService dans le conteneur de dépendances (requis) ;
  • inscription dans le pipeline du middleware de gestion d’exceptions ;
  • suppression du middleware ExceptionHandlingMiddleware.

Nous pouvons maintenant relancer notre API Web. Depuis Scalar, l’appel vers /weatherforecast retourne :

  • Le code statut est : 500 Internal Server Error ;
  • Le body est de type : application/json ;
  • Le body contient le JSON :
{
"title": "Server Error",
"status": 500
}

Le résultat est identique au middleware générique que nous avons développé précédemment. Cependant, les gestionnaires héritant de IExceptionHandler sont spécialisés et ne sont invoqués qu’en cas d’exception. L’intention est ainsi beaucoup plus claire et nous ne pouvons pas modifier la requête ou la réponse en dehors du traitement de l’exception.

Nous avons présenté jusqu’à présent un cas d’usage assez simple : un gestionnaire “attrape-tout” qui intercepte toutes les exceptions.

Nous allons maintenant voir un autre cas d’usage avec une gestion spécifique par exception.

Créons le dossier Exceptions, puis les deux fichiers UnknownEntityException.cs et RequestValidationException.cs sous ce dossier.

  • RépertoireExceptionFilterWebApi
    • RépertoireProperties
      • launchSettings.json
    • RépertoireExceptions
      • RequestValidationException.cs
      • UnknownEntityException.cs
    • RépertoireHandlers
      • GlobalExceptionsHandler.cs
    • RépertoireMiddlewares
      • ExceptionHandlingMiddleware.cs
    • appsettings.json
    • appsettings.Development.json
    • ExceptionFilterWebApi.http
    • Program.cs
Exceptions/RequestValidationException.cs
namespace ExceptionFilterWebApi.Exceptions;
internal sealed class RequestValidationException : Exception
{
public RequestValidationException() { }
public RequestValidationException(string message) : base(message) { }
}
Exceptions/UnknownEntityException.cs
namespace ExceptionFilterWebApi.Exceptions;
internal sealed class UnknownEntityException : Exception
{
public UnknownEntityException() { }
public UnknownEntityException(string message) : base(message) { }
}

Ces deux exceptions sont très similaires : elles héritent de Excecption et implémentent deux constructeurs de la classe de base.

Sous le dosser Handlers, créons deux nouveaux gestionnaires : RequestValidationExceptionHandler.cs et UnknownEntityExceptionHandler.cs.

  • RépertoireExceptionFilterWebApi
    • RépertoireProperties
      • launchSettings.json
    • RépertoireExceptions
      • RequestValidationException.cs
      • UnknownEntityException.cs
    • RépertoireHandlers
      • GlobalExceptionsHandler.cs
      • RequestValidationExceptionHandler.cs
      • UnknownEntityExceptionHandler.cs
    • RépertoireMiddlewares
      • ExceptionHandlingMiddleware.cs
    • appsettings.json
    • appsettings.Development.json
    • ExceptionFilterWebApi.http
    • Program.cs
Handlers/RequestValidationExceptionHandler.cs
using ExceptionFilterWebApi.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class RequestValidationExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not RequestValidationException requestValidationException)
{
return false;
}
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Bad Request",
Detail = requestValidationException.Message
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
Handlers/UnknownEntityExceptionHandler.cs
using ExceptionFilterWebApi.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
public class UnknownEntityExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not UnknownEntityException unknownEntityException)
{
return false;
}
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Not Found",
Detail = unknownEntityException.Message
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}

Chaque gestionnaire traite son exception :

  • RequestValidationExceptionRequestValidationExceptionHandler ;
  • UnknownEntityExceptionUnknownEntityExceptionHandler.

Si l’exception n’est pas celle traitée par le gestionnaire, alors il retourne false afin d’indiquer qu’il n’a pas géré l’exception.

Sinon un nouveau ProblemDetails est créé avec le code status HTTP correspondant à l’exception :

  • RequestValidationException → code status HTTP 400 Bad Request ;
  • UnknownEntityException → code status HTTP 404 Not Found.

Le gestionnaire retourne alors true pour indiquer qu’il a géré l’exception.

Nous allons maintenant voir comment déclarer ses deux nouveaux gestionnaires d’exception.

Program.cs
using ExceptionFilterWebApi.Handlers;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Register exceptions handler
builder.Services.AddExceptionHandler<RequestValidationExceptionHandler>();
builder.Services.AddExceptionHandler<UnknownEntityExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionsHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseExceptionHandler();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
throw new Exception("Weather forecast");
})
.WithName("GetWeatherForecast");
app.Run();

Rien de plus simple : il suffit juste de les ajouter dans le conteneur de dépendances.

Par contre, l’ordre d’enregistrement des gestionnaires est important. Nous avons conservé et positionné en dernier GlobalExceptionsHandler afin qu’il serve de ramasse-miettes pour gérer les exceptions autres que RequestValidationException ou UnknownEntityException.

Nous allons modifier notre API afin qu’elle exploite les exceptions que nous venons de créer.

Program.cs
using ExceptionFilterWebApi.Handlers;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// Register exceptions handler
builder.Services.AddExceptionHandler<RequestValidationExceptionHandler>();
builder.Services.AddExceptionHandler<UnknownEntityExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionsHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseExceptionHandler();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
throw new Exception("Weather forecast");
})
.WithName("GetWeatherForecast");
app.MapGet("/summary/{id}", (int id) =>
{
if (id < 0)
{
throw new RequestValidationException($"'{id}' is not a valid id.");
}
if (id > summaries.Length - 1)
{
throw new UnknownEntityException($"No summary found for '{id}'");
}
return summaries[id];
})
.WithName("GetSummary");
app.Run();

Nous avons créé un nouvel endpoint /summary/{id} qui retourne l’une des valeurs du tableau summaries. Le paramètre id est utilisé comme index du tableau : return summaries[id].

Avant de retourner la valeur, nous vérifions d’abord que id :

  • n’est pas négatif, sinon RequestValidationException est levée ;
  • n’est pas hors index, sinon UnknownEntityException est levée.

Nous avons quelques cas de tests à vérifier.

Tout d’abord vérifions la réponse à l’appel vers /weatherforecast.

  • Le code statut est : 500 Internal Server Error ;
  • Le body est de type : application/json ;
  • Le body contient le JSON :
{
"title": "Server Error",
"status": 500
}

Rien n’a changé pour cet endpoint.

Testons maintenant /summary/{id}. Depuis le menu de gauche de Scalar, cliquons sur /summary/{id} GET, puis sur ▶ Test Request dans la section à droite. La popin correspondante est ouverte et nous pouvons indiquer une valeur pour le paramètre id. Ce paramètre est présent sur la partie gauche, sous Path Variables.

Saisissons la valeur id = 0, puis appuyons sur ▶ Send.

  • Le code statut est : 200 OK ;
  • Le body est de type : text/plain ;
  • Le body a pour valeur : "Freezing", première entrée du tableau summaries.

Modifions le paramètre id = 9, puis appuyons sur ▶ Send.

  • Le code statut est : 200 OK ;
  • Le body est de type : text/plain ;
  • Le body a pour valeur : "Scorching", dernière entrée du tableau summaries.

Que se passe-t-il avec la valeur id = 10 ?

  • Le code statut est : 404 Not Found.
  • Le body est de type : application/json, a pour valeur ;
  • Le body contient le JSON :
{
"title": "Not Found",
"status": 404,
"detail": "No summary found for '10'"
}

Il s’agit bien du ProblemDetails que nous avons paramétré pour l’exception UnknownEntityException.

Maintenant testons avec la valeur id = -1.

  • Le code statut est : 400 Bad Request.
  • Le body est de type : application/json, a pour valeur ;
  • Le body contient le JSON :
{
"title": "Bad Request",
"status": 400,
"detail": "'-1' is not a valid id."
}

Ce ProblemDetails est également celui que nous avons codé pour RequestValidationException.

Les gestionnaires d’exception sont un moyen très pratique de personnaliser le traitement des exceptions levées dans la Web Api.

Nous avons utilisé régulièrement la classe ProblemDetails. Selon la documentation Microsoft, cette classe propose un :

Format lisible par l’ordinateur pour spécifier des erreurs dans les réponses de l’API HTTP basées sur https://tools.ietf.org/html/rfc7807.

La norme RFC 7807 définit un “problem details”. Mais ne nous y attardons pas, car elle a été remplacée par la norme RFC 9457.

Certes, mais quel est l’objectif de cette norme RFC 9457 ? Elle répond à un besoin de standardiser le format retourné par une API Web lorsqu’une erreur survient. Et justement la classe ProblemDetails nous aide à utiliser ce format standard.

Voici un exemple de ProblemDetails

{
"type": "https://tools.ietf.org/html/rfc9110#name-400-bad-request",
"title": "Bad Request",
"status": 400,
"detail": "'-1' is not a valid id.",
"instance": "GET /summary/-1"
}

Nous allons détailler chaque propriété.

La propriété Type est définie dans le chapitre 3.1.1. Elle est de type string et contient un URI vers la documentation du type de problème. Cette documentation doit être compréhensible par un humain.

Les codes statut HTTP sont listés sur la norme RFC 9110 :

Nous pouvons ainsi récupérer le lien de code statut HTTP du problème et nous en servir comme valeur de la propriété Type :

Mais nous pouvons également fournir notre propre URI si nous souhaitons décrire nous-même le type de problème.

La propriété Status est définie dans le chapitre 3.1.2. Elle est de type int et contient le code statut HTTP (400, 404, 500, etc.) associé à cette occurrence du problème. Ce code doit être obligatoirement identique à celui de la réponse HTTP.

La propriété Title est définie dans le chapitre 3.1.3. Elle est de type string et contient un texte court décrivant le type de problème. Ce texte doit être compréhensible par un humain. Un type de problème doit toujours avoir le même titre.

La propriété Detail est définie dans le chapitre 3.1.4. Elle est de type string et contient un texte décrivant cette occurrence du problème. Ce texte doit être compréhensible par un humain.

La propriété Instance est définie dans le chapitre 3.1.5. Elle est de type string et contient l’URI qui a déclenché cette occurrence du problème.

La propriété Extensions est définie dans le chapitre 3.2. Elle est de type Dictionary<string, object?> et permet d’ajouter des informations personnalisées et spécifiques au type de problème.

Nous avons ajouté le service IProblemDetailsService via l’instruction :builder.Services.AddProblemDetails() Mais nous ne l’avons pas utilisé dans nos gestionnaires.

Reprenons le code de GlobalExceptionHandler afin d’utiliser IProblemDetailsService.

Handlers/GlobalExceptionHandler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace ExceptionFilterWebApi.Handlers;
internal sealed class GlobalExceptionsHandler : IExceptionHandler
internal sealed class GlobalExceptionsHandler(
IProblemDetailsService problemDetailsService
) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error",
Type = "https://tools.ietf.org/html/rfc9110#name-500-internal-server-error",
};
httpContext.Response.StatusCode = problemDetails.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
Exception = exception,
HttpContext = httpContext,
ProblemDetails = problemDetails
});
}
}

Nous avons injecté IProblemDetailsService. Ce service nous donne accès à deux méthodes TryWriteAsync() et WriteAsync().

Nous avons utilisé la méthode TryWriteAsync() qui retourne true si elle a bien réussi à écrire le ProblemDetails dans le body de la réponse.

Relançons le projet.

Sur la page Scalar, l’appel vers /weatherforecast retourne :

  • Le code statut est : 500 Internal Server Error ;
  • Le body est de type : application/problem+json ;
  • Le body contient le JSON :
{
"type": "https://tools.ietf.org/html/rfc9110#name-500-internal-server-error",
"title": "Server Error",
"status": 500,
"traceId": "00-751fd9b56104df2753c9e16aecfae368-0fcf40099134de5d-00"
}

Le type est bien celui contient bien l’URI que nous avons défini

La propriété traceId a été ajoutée automatiquement. Elle contient une valeur générée aléatoirement et différente à chaque fois. C’est facile à vérifier en renouvelant l’appel.

Le type du body a changé : application/jsonapplication/problem+json. Le type de contenu application/problem+json est bien celui indiqué dans la norme : The Problem Details JSON Object.

L’objet ProblemDetails nous permet de retourner une réponse normalisée lorsqu’une erreur survient dans notre API Web. L’objet est extensible et permet de s’adapter à tous nos besoins.

Dans cet article, nous avons de nouveau pu définir un middleware personnalisé, puis un gestionnaire d’exception. Ce dernier est également un middleware, mais dédié à une seule tâche : gérer les exceptions.

Les services IExceptionHandler et IProblemDetailsService forment un duo performant qui nous permet, en cas d’erreur, de générer un ProblemDetails conforme à la norme RFC 9457.

Nous pouvons personnaliser le ProblemDetails grâce à la propriété Extensions afin d’ajouter toutes les informations nécessaires pour le consommateur de notre API Web.

Pour conclure, nous devons garder en mémoire qu’il est important :

  • de contrôler au maximum toutes les erreurs attendues et non attendues ;
  • de filtrer les informations sensibles afin de ne pas les exposer à des gens malveillants ;
  • de donner les informations strictement nécessaires lorsqu’une erreur contrôlée survient.

C’est un équilibre subtil entre expérience développeur (DX) et sécurité. Selon les contextes, il faut parfois sacrifier l’un au détriment de l’autre.