ProblemDetails en ASP.NET Core : erreurs propres avec TraceId
Mapper les exceptions vers ProblemDetails (RFC 7807) et ajouter un traceId pour des erreurs cohérentes et un diagnostic rapide dans les logs.
Lorsqu’une exception survient dans une API, le client a malgré tout besoin d’une réponse d’erreur cohérente, sûre et facile à exploiter. Renvoyer des structures JSON différentes selon les endpoints (ou pire : des stack traces) rend le code client fragile et complique inutilement le débogage.
Une approche propre consiste à renvoyer les erreurs au format Problem Details (RFC 7807)
et à inclure un traceId afin de pouvoir rattacher la requête en échec à l’entrée correspondante dans les logs.
Avec .NET 8, la mise en place moderne s’appuie sur le pipeline intégré IExceptionHandler avec AddProblemDetails().
Objectif
- Renvoyer les erreurs dans un format standard (ProblemDetails).
- Mapper les types d’exceptions sur les bons codes HTTP (400/404/409/500).
- Ajouter un
traceIdpour une corrélation rapide avec les logs. - Garder des réponses adaptées à la production (ne pas exposer de stack traces).
Qu’est-ce que ProblemDetails ?
ProblemDetails est un format standardisé de payload d’erreur pour les API HTTP. Il contient généralement :
type: une URI qui identifie le type d’erreur (souvent un lien vers la documentation).title: un résumé court et lisible.status: le code de statut HTTP.detail: des détails (à garder « safe » en production).instance: une référence à l’occurrence précise (optionnel).
Le RFC 7807 autorise aussi des champs supplémentaires (extension members). Dans cet exemple, on ajoute traceId.
Configuration Program.cs (AddProblemDetails + IExceptionHandler)
AddProblemDetails() enregistre l’infrastructure Problem Details.
AddExceptionHandler<T>() enregistre un handler qui transforme les exceptions en réponses ProblemDetails.
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// 1) ProblemDetails + personnalisation globale (ajoute traceId à toutes les réponses ProblemDetails)
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
var http = ctx.HttpContext;
var traceId = Activity.Current?.Id ?? http.TraceIdentifier;
// Ajouter traceId à chaque ProblemDetails (y compris, le cas échéant, les erreurs de validation)
ctx.ProblemDetails.Extensions["traceId"] = traceId;
// Default utile pour instance
ctx.ProblemDetails.Instance ??= http.Request.Path;
};
});
// 2) Enregistrer le handler d'exceptions (IExceptionHandler)
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
// 3) Middleware intégré de gestion des exceptions
app.UseExceptionHandler();
app.MapControllers();
app.Run();
GlobalExceptionHandler (Mapper les exceptions vers des status codes)
IExceptionHandler permet de centraliser la logique exception → réponse.
Les exceptions connues renvoient des status HTTP pertinents (404/409/400).
Les erreurs inconnues renvoient 500 avec un message sûr (générique).
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger = logger;
private readonly IProblemDetailsService _problemDetailsService = problemDetailsService;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (status, title, type, detail) = Map(exception);
// Logger tous les détails côté serveur (ne pas les exposer au client)
_logger.LogError(exception, "Unhandled exception ({Status}).", status);
var problem = new ProblemDetails
{
Status = status,
Title = title,
Type = type,
Detail = detail,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = status;
// Écrit "application/problem+json" et applique la personnalisation AddProblemDetails (traceId, etc.)
return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = problem,
Exception = exception
});
}
private static (int status, string title, string type, string detail) Map(Exception ex)
{
return ex switch
{
NotFoundException nf => (StatusCodes.Status404NotFound, "Not found", "https://httpstatuses.com/404", nf.Message),
ConflictException cf => (StatusCodes.Status409Conflict, "Conflict", "https://httpstatuses.com/409", cf.Message),
ValidationException ve => (StatusCodes.Status400BadRequest, "Validation error", "https://httpstatuses.com/400", ve.Message),
// Inconnu / inattendu
_ => (StatusCodes.Status500InternalServerError, "Unexpected error", "https://httpstatuses.com/500",
"Une erreur inattendue s’est produite.")
};
}
}
Exemples d’exceptions
Des types d’exceptions simples et explicites rendent le mapping clair et lisible :
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ConflictException(string message) : Exception(message);
public sealed class ValidationException(string message) : Exception(message);
Exemple de réponse
La réponse garde une structure prévisible et inclut traceId :
{
"type": "https://httpstatuses.com/404",
"title": "Not found",
"status": 404,
"detail": "Customer was not found.",
"instance": "/api/customers/42",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
Côté client : utiliser traceId pour le support / debug
Le code client peut afficher un message plus clair et conserver traceId comme référence pour l’analyse :
const res = await fetch("/api/customers/42");
if (!res.ok) {
const contentType = res.headers.get("Content-Type") || "";
let payload;
if (contentType.includes("application/problem+json") || contentType.includes("application/json")) {
payload = await res.json();
} else {
// Fallback: HTML/plain-text, etc.
payload = { title: "Unexpected error", detail: await res.text() };
}
console.error("API error:", payload);
// Avec ProblemDetails, traceId peut être présent (sinon vide)
const traceId = payload?.traceId || payload?.extensions?.traceId;
alert(`Une erreur s’est produite.${traceId ? ` Référence : ${traceId}` : ""}`);
}
Améliorations courantes
- Ajouter un
errorCodespécifique à l’application (ex."CUSTOMER_NOT_FOUND") dansextensions. - Renvoyer les erreurs de validation par champ sous forme
{ field, message }[](peut faire un example séparé). - Utiliser une URL
typestable pointant vers votre propre documentation au lieu dehttpstatuses.com. - Pour les erreurs 500, garder
detailgénérique en production (ce que fait cet exemple).
TL;DR
- Utilisez ProblemDetails (RFC 7807) pour des réponses d’erreur cohérentes.
- Avec .NET 8, l’approche moderne est :
IExceptionHandler+AddProblemDetails(). - Ajoutez
traceIdviaCustomizeProblemDetailspour corréler les erreurs avec les logs. - N’exposez pas de stack traces en production.