ASP.NET Core Exception Handle & RFC 7807 Problem Details for HTTP APIs

2024-07-08

筆記 ASP.NET Core Exception Hadnel 與 RFC 7807 Problem Details for HTTP APIs 在

logo

說明

Exception Handle 的設計目標:

  • Production 不要出現 Exception 的 Stack Trace 避免暴露資訊
  • Development 可以看到 Exception 的 Stack Trace 方便 Debug
  • Response 要符合 RFC 7807 Problem Details for HTTP APIs
  • 針對 HTTP Status Code 4xx, 5xx 的 Exception 進行處理

設計

為了要在 Development 環境可以看到 Exception 的 Stack Trace,可以透過 UseDeveloperExceptionPage Middleware 來達成,而在 .NET 8 當中預設已經有加入這個 Middleware,在 Development 環境下會自動加入,但是在 Production 環境下不會加入。

簡易驗證的程式碼:

app.MapGet("/Exception", () =>
{
    throw new NotImplementedException();
});

如果要測試 Production 環境下的效果,可以透過 ASPNETCORE_ENVIRONMENT 或直接在 Program.cs 設定 builder.Environment 來進行設定。

app.Environment.EnvironmentName = Environments.Production;

所以預設上不需要特別調整 Program.cs 就可達到 Exception Stack Trace 針對 Development 環境顯示,Production 環境不顯示的效果。

而如果要再加上符合 RFC 7807 Problem Details for HTTP APIs 的 Response,可以透過 UseProblemDetails Middleware 來達成,這個 Middleware 會將 Exception 轉換成符合 RFC 7807 的 Response。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();

Without RFC 7807 Response Format

Match RFC 7807 Response Format


完成以上的設定對於 HTTP Status Code 5xx 的處理已經完成,但是對於 HTTP Status Code 4xx 的處理則需要另外處理。

app.UseStatusCodePages(async statusCodeContext =>
{
    await Results.Problem(statusCode: statusCodeContext.HttpContext.Response.StatusCode).ExecuteAsync(statusCodeContext.HttpContext);
});

app.Run();

Results.Problem 能夠與 AddProblemDetails 一樣產生符合 RFC 7807 的 Response,這樣就能夠將 HTTP Status Code 4xx 的 Exception 也轉換成符合 RFC 7807 的 Response。

同時 Problem 所提供的參數,例如 title, detail, instance 來呈現更多的資訊。

而如果想要針對 Exception 有更多客製化處理需求,可以透過 UseExceptionHandler Middleware 來進行處理。

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var exceptionHandlerFeature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
        if (exceptionHandlerFeature?.Error is Exception ex)
        {
            var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
            logger.LogError(ex, "An unhandled exception occurred.");

            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
            context.Response.ContentType = MediaTypeNames.Application.Json;

            var problemDetails = new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "An error occurred while processing your request.",
                Detail = "Please try again later.",
                Instance = context.Request.Path
            };

            var json = JsonSerializer.Serialize(problemDetails);
            await context.Response.WriteAsync(json);
        }
    });
});

TLDR

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler(errorApp =>
    {
        errorApp.Run(async context =>
        {
            var exceptionHandlerFeature = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
            if (exceptionHandlerFeature?.Error is Exception ex)
            {
                var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
                logger.LogError(ex, "An unhandled exception occurred.");

                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
                context.Response.ContentType = MediaTypeNames.Application.Json;

                var problemDetails = new ProblemDetails
                {
                    Status = StatusCodes.Status500InternalServerError,
                    Title = "An error occurred while processing your request.",
                    Detail = "Please try again later.",
                    Instance = context.Request.Path
                };

                var json = JsonSerializer.Serialize(problemDetails);
                await context.Response.WriteAsync(json);
            }
        });
    });
}

app.UseStatusCodePages(async statusCodeContext =>
{
    await Results.Problem(statusCode: statusCodeContext.HttpContext.Response.StatusCode).ExecuteAsync(statusCodeContext.HttpContext);
});