Gestione delle eccezioni in ASP.NET Core: gli errori delle API in un formato standard con Problem Details
Nell’articolo sulla modularità il modulo di firma
digitale esponeva due rotte: POST /signatures per chiedere la firma di un documento
e GET /signatures/{id} per controllarne lo stato. Restava fuori una domanda: cosa
riceve il client quando qualcosa va storto?
Se non si configura niente, poco. Un’eccezione nel caso d’uso diventa un 500 con il
corpo vuoto, e lo stesso vale per un documento che non esiste: il client sa solo che
qualcosa è andato male, non cosa. Quando poi ogni endpoint inventa il suo formato, uno
{ "error": "..." }, un altro { "message": "..." }, un altro una stringa, il
frontend deve conoscerli tutti.
Gli esempi usano lo stesso documentale inventato, DocumentaleInventato.
Senza configurare niente
Ecco cosa succede in produzione con un’app appena creata, se la richiesta di firma lancia un’eccezione o se la rotta non esiste:
POST /signatures → 500, corpo vuoto
GET /nessuna-rotta → 404, corpo vuoto
In Development l’eccezione arriva invece al client con tutto lo stack trace: è la
pagina di errore per sviluppatori, che WebApplication attiva da sola in quell’ambiente.
Utile sulla propria macchina, ma il formato è un altro ancora.
Problem Details: un formato standard
Problem Details è il formato descritto dalla RFC 9457, che ha sostituito la 7807:
un oggetto JSON, con content type application/problem+json, che ha sempre la stessa
forma.
{
"type": "https://errori.documentale-inventato.example/firma-gia-richiesta",
"title": "Firma già richiesta",
"status": 409,
"detail": "Il documento 3f2a… ha già una firma in corso.",
"instance": "/signatures"
}
type: un URI che identifica il tipo di problema. È il campo su cui il client decide cosa fare; se manca valeabout:blank.title: una descrizione breve del tipo, uguale per tutte le volte che si presenta.status: lo status HTTP, ripetuto nel corpo.detail: cosa è successo questa volta, scritto per una persona.instance: un URI che identifica questa occorrenza.
Si possono aggiungere altri campi, le estensioni. Il client che conosce il formato
legge type e detail da qualunque endpoint, senza casi particolari.
1. Tre righe in Program.cs
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
AddProblemDetails registra IProblemDetailsService, il servizio che scrive le
risposte in questo formato. I due middleware lo usano:
UseExceptionHandlercattura le eccezioni dei middleware e degli endpoint che vengono dopo, per questo va messo all’inizio. SenzaAddProblemDetails, chiamato così senza argomenti, fa fallire l’avvio dell’app.UseStatusCodePagesriempie le risposte di errore rimaste senza corpo: una rotta che non esiste, o un endpoint che restituisceTypedResults.NotFound().
Ora la stessa eccezione di prima risponde così:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "0HNPATSE8SHTF:00000003"
}
E la rotta che non esiste ha un 404 con "title": "Not Found". Con i controller e
[ApiController] anche NotFound() esce nello stesso formato.
Il servizio scrive JSON solo se il client lo accetta: con Accept: application/json,
application/problem+json o */* sì, con Accept: text/html no, e la risposta
resta con lo status giusto e il corpo vuoto.
Il traceId: dalla risposta alla trace
Il campo traceId lo aggiunge ASP.NET Core, senza configurarlo. Senza tracing è
l’identificativo della richiesta, come sopra. Con OpenTelemetry attivo, come
nell’articolo sulle trace, diventa l’id W3C
dell’Activity corrente:
"traceId": "00-9c09e58b489d5e5993b1f1f8ddad9c3f-1459bf54cf2bc549-01"
Il secondo pezzo è il trace id. Chi segnala l’errore copia la risposta, e da lì si arriva alla trace della richiesta e ai log scritti durante quella richiesta, senza cercare per orario.
2. Le eccezioni del dominio
Il caso d’uso che richiede la firma sa due cose che l’endpoint non sa: se il documento esiste e se ha già una firma in corso. Lo dice con due eccezioni, in Application:
public sealed class DocumentoNonTrovatoException(Guid documentoId)
: Exception($"Il documento {documentoId} non esiste.");
public sealed class FirmaGiaRichiestaException(Guid documentoId)
: Exception($"Il documento {documentoId} ha già una firma in corso.");
Non sanno niente di HTTP: Application non deve sapere che dietro c’è un’API. Senza
altro, diventano dei 500, ma non sono errori del server: il primo è un 404, il
secondo un 409 Conflict.
La traduzione la fa un IExceptionHandler, nel progetto Module, l’unico che conosce
sia le eccezioni sia l’HTTP:
internal sealed class FirmaExceptionHandler(IProblemDetailsService problems)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext http, Exception ex, CancellationToken ct)
{
var (status, tipo, titolo) = ex switch
{
DocumentoNonTrovatoException =>
(404, "documento-non-trovato", "Documento non trovato"),
FirmaGiaRichiestaException =>
(409, "firma-gia-richiesta", "Firma già richiesta"),
_ => (0, "", "")
};
if (status == 0) return false;
http.Response.StatusCode = status;
await problems.TryWriteAsync(new()
{
HttpContext = http,
Exception = ex,
ProblemDetails =
{
Status = status,
Type = $"https://errori.documentale-inventato.example/{tipo}",
Title = titolo,
Detail = ex.Message
}
});
return true;
}
}
Il middleware chiama gli handler registrati, nell’ordine di registrazione, finché uno
restituisce true. Se tutti restituiscono false si arriva al comportamento di
prima: il 500 generico. Così un handler si occupa solo delle eccezioni che conosce.
Alcuni dettagli:
return trueanche se la scrittura non riesce.TryWriteAsyncrestituiscefalsequando il client non accetta JSON. Se l’handler restituisse quelfalse, il middleware considererebbe l’eccezione non gestita, e il404tornerebbe un500.Detail = ex.Messagesolo qui. I messaggi di queste eccezioni sono scritti per il client. Quello di un’eccezione qualsiasi no: può contenere una connection string o il nome di una tabella.- Il client decide su
type. Due409possono avere cause diverse; iltitlesi può tradurre o riscrivere, iltyperesta. - È un singleton. Gli handler vivono quanto l’app: nel costruttore vanno solo
servizi singleton, come
IProblemDetailsServiceo unILogger.
L’handler si registra dove si registra il resto del modulo:
builder.Services.AddExceptionHandler<FirmaExceptionHandler>();
L’host chiama UseExceptionHandler una volta sola. Ogni modulo porta il suo handler
per le sue eccezioni, e un host che monta tre moduli non deve conoscerle.
3. Un campo per tutte le risposte
CustomizeProblemDetails viene chiamato per ogni risposta scritta da
IProblemDetailsService, da chiunque arrivi: il middleware delle eccezioni, le status
code pages, gli handler. Qui aggiunge instance:
builder.Services.AddProblemDetails(o =>
o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path);
È anche il posto giusto per un’estensione che deve esserci sempre, per esempio il nome del modulo o della versione dell’API.
Quello che il client non deve vedere
L’eccezione dell’esempio aveva questo messaggio:
Connection string 'Server=sql-prod-01;User Id=firma;...' non valida
Nel 500 generico non ce n’è traccia: title è una frase fissa e detail manca. Il
messaggio e lo stack trace finiscono solo nel log. È il motivo per cui Detail = ex.Message va scritto solo per le eccezioni il cui messaggio lo scriviamo noi, come
quelle del dominio: lì sappiamo che non contiene niente di riservato.
In Development la tentazione è tenere la pagina per sviluppatori, e molti esempi lo
fanno così:
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler();
Ma senza il middleware nessuno chiama gli handler: in sviluppo il documento che non
esiste torna un 500, con lo stack trace nel campo exception, mentre in produzione
è un 404. L’API si comporta in due modi diversi, e il frontend si prova su quello
sbagliato. Meglio chiamare UseExceptionHandler sempre: lo stack trace resta comunque
nel log.
Il log, da .NET 10
Per un’eccezione che nessun handler gestisce, il middleware scrive un errore nel log, con l’eccezione:
Error ExceptionHandlerMiddleware: An unhandled exception has occurred while executing the request.
e aggiunge il tag error.type alla metrica http.server.request.duration.
Fino a .NET 9 lo faceva anche per le eccezioni gestite da un IExceptionHandler.
Da .NET 10 no: se TryHandleAsync restituisce true, niente log e niente
error.type. Per il 404 e il 409 della firma è quello che serve: non sono errori
del server, e non devono far scattare un allarme.
Se invece un handler gestisce errori veri, per esempio un 503 quando il provider di
firma non risponde, il log lo scrive l’handler, oppure si torna al comportamento di
prima:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
SuppressDiagnosticsCallback = _ => false
});
La callback riceve l’eccezione e la richiesta, e può decidere caso per caso.
Cambiamento significativo: la diagnostica delle eccezioni viene soppressa quando IExceptionHandler.TryHandleAsync restituisce trueUn caso a parte sono le richieste annullate dal client: il middleware le riconosce,
risponde 499 e non scrive un errore. Ne parlavamo nell’articolo sul
CancellationToken.
Non tutto è un’eccezione
GET /signatures/{id} con un id che non esiste non è un caso eccezionale: è una
risposta possibile, e l’endpoint la restituisce come tale.
static async Task<IResult> GetSignature(
Guid id, IFirme firme, CancellationToken ct)
{
var firma = await firme.TrovaAsync(id, ct);
return firma is null ? TypedResults.NotFound() : TypedResults.Ok(firma);
}
TypedResults.NotFound() non ha corpo, e UseStatusCodePages lo riempie: il client
riceve lo stesso formato. Le eccezioni servono quando chi scopre il problema sta in
profondità, come il caso d’uso, e farlo risalire fino all’endpoint con dei valori di
ritorno vorrebbe dire cambiare il tipo restituito da ogni metodo lungo la strada.
Anche la validazione dell’input non passa dalle eccezioni. In .NET 10 le Minimal API
hanno la validazione integrata, con gli attributi di DataAnnotations:
public sealed record RichiestaFirma(
Guid DocumentoId,
[Required, MaxLength(100)] string Firmatario);
builder.Services.AddValidation();
Un firmatario vuoto non arriva all’endpoint: la risposta è un 400 nello stesso
formato, con l’estensione errors, un campo per proprietà.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"instance": "/signatures",
"errors": { "Firmatario": ["The Firmatario field is required."] },
"traceId": "0HNPATSE8SHTF:0000000A"
}
Validazione in ASP.NET Core
I messaggi di validazione
I messaggi predefiniti sono in inglese e usano il nome della proprietà. Ogni attributo
accetta il suo messaggio in ErrorMessage: {0} è il nome del campo, gli altri
segnaposto dipendono dall’attributo: per MaxLength, {1} è la lunghezza massima.
Il nome del campo si cambia con Display:
public sealed record RichiestaFirma(
Guid DocumentoId,
[Required(ErrorMessage = "Indica chi deve firmare.")]
[MaxLength(100, ErrorMessage = "{0} può avere al massimo {1} caratteri.")]
[Display(Name = "Il firmatario")]
string Firmatario);
Con un firmatario di 101 caratteri:
"errors": { "Firmatario": ["Il firmatario può avere al massimo 100 caratteri."] }
La chiave in errors resta il nome della proprietà C#: Display cambia solo il testo
del messaggio.
Il title è lo stesso per tutti gli errori di validazione. Si cambia in
CustomizeProblemDetails, riconoscendo la risposta dal suo tipo:
builder.Services.AddProblemDetails(o =>
o.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path;
if (ctx.ProblemDetails is HttpValidationProblemDetails)
ctx.ProblemDetails.Title = "La richiesta contiene dati non validi.";
});
Per servire più lingue, in .NET 10 ogni attributo può prendere il messaggio da un file
di risorse, con ErrorMessageResourceType e ErrorMessageResourceName. Da .NET 11,
con AddLocalization, la validazione cerca i messaggi nelle risorse anche da sola,
con chiavi per convenzione come RequiredAttribute_Error: si traducono una volta per
tutta l’app, senza ripeterli su ogni attributo.
I messaggi in italiano sono solo un esempio: ErrorMessage serve a cambiare il testo,
non la lingua. Molte API enterprise restano in inglese, e spesso è il frontend a
tradurre, partendo da type o dalla chiave in errors. Le risorse servono quando è
l’API a dover rispondere nella lingua di chi chiama. L’internazionalizzazione di
un’API avrà un articolo a sé.
Nel contratto OpenAPI
Il client deve sapere quali errori aspettarsi. ProducesProblem li aggiunge al
documento OpenAPI, con content type application/problem+json:
g.MapPost("/", RequestSignature)
.ProducesProblem(404)
.ProducesProblem(409);
Includere i metadati OpenAPI in un’app ASP.NET Core
In breve
AddProblemDetails,UseExceptionHandlereUseStatusCodePages: ogni errore nello stesso formato.- Le eccezioni del dominio diventano
4xxin unIExceptionHandler, nel modulo che le conosce. - Il client decide su
type; iltraceIdporta alla trace. - Nel
500nessun dettaglio interno, eUseExceptionHandleranche in sviluppo. - Da .NET 10 le eccezioni gestite non finiscono nel log da sole.
Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.