Gestione delle eccezioni in ASP.NET Core: gli errori delle API in un formato standard con Problem Details

· 9 min di lettura

Scritto per .NET 10 · ASP.NET Core 10

#tech#dotnet

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"
}

Si possono aggiungere altri campi, le estensioni. Il client che conosce il formato legge type e detail da qualunque endpoint, senza casi particolari.

RFC 9457: Problem Details for HTTP APIsDocumentazione ufficiale · www.rfc-editor.org

1. Tre righe in Program.cs

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:

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.

Gestire gli errori nelle API di ASP.NET CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

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:

Eccezioni.cs
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:

FirmaExceptionHandler.cs
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:

L’handler si registra dove si registra il resto del modulo:

DependencyInjection.cs
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.

Gestire gli errori in ASP.NET CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

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:

Program.cs
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ì:

Program.cs
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:

Program.cs
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 trueDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

Un 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.

Endpoints.cs
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:

Endpoints.cs
public sealed record RichiestaFirma(
    Guid DocumentoId,
    [Required, MaxLength(100)] string Firmatario);
Program.cs
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 CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

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:

Endpoints.cs
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:

Program.cs
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:

Endpoints.cs
g.MapPost("/", RequestSignature)
    .ProducesProblem(404)
    .ProducesProblem(409);
Includere i metadati OpenAPI in un’app ASP.NET CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

In breve

  1. AddProblemDetails, UseExceptionHandler e UseStatusCodePages: ogni errore nello stesso formato.
  2. Le eccezioni del dominio diventano 4xx in un IExceptionHandler, nel modulo che le conosce.
  3. Il client decide su type; il traceId porta alla trace.
  4. Nel 500 nessun dettaglio interno, e UseExceptionHandler anche in sviluppo.
  5. 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ì.