I'm currently learning about error error management in ASP.NET Core Web API projects to try to find an elegant solution that ensures that a ProblemDetails
is always returned when something goes wrong.
I am using the default WeatherForecast
API template that is created when making a new C# / ASP.NET Core 8 Web API Project with controllers.
I have implemented a custom exception handler; however I'm finding that it does not always work.
When I call the API using Postman
to cause the error, I'm getting the correct ProblemDetails
, status code, and header information; however when I call the API from the "try it" feature in the Swagger
interface, I am getting back the wrong status code (a 500 internal error) with exception details instead of a ProblemDetails
and the wrong header information.
Looking into this, I found that in my GlobalExceptionHandler
class (that implements IExceptionHandle
interface), the ProblemDetailsService.TryWriteAsync(...)
method returns false
if I'm trying the code from Swagger; however, when I execute the exact same code by calling it from Postman the ProblemDetailsService.TryWriteAsync(...)
method returns true
.
I put a case for when the 'ProblemDetailsService.TryWriteAsync(...)' returns fails so that I could try to directly write the ProblemDetailsContext
to the httpContext.Response
.
When I did this I was able to catch the following exception (again this only occurs when using Swagger's Try Code feature....not when calling from Postman):
Serialization and deserialization of 'System.Type' instances is not supported. Path: $.HttpContext.Features.Key."
I don't know why this is failing.
I am looking for help on how to ensure my error management behaving the same way regardless of how the API is called.
This is the implementation for my GlobalExceptionHandler
class:
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetailsService;
public GlobalExceptionHandler(IProblemDetailsService problemDetailsService)
{
if (problemDetailsService == null) throw new ArgumentException(nameof(problemDetailsService));
_problemDetailsService = problemDetailsService;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var message = exception switch
{
ArgumentException => ((ArgumentException)exception).Message,
ValidationException => ((ValidationException)exception).Message,
_ => $"Internal Server Error ({exception.GetType().Name})"
};
int status = exception switch
{
ArgumentException => (int)HttpStatusCode.BadRequest,
ValidationException => (int)HttpStatusCode.BadRequest,
_ => (int)HttpStatusCode.InternalServerError
};
ProblemDetails problemDetails = new()
{
Title = $"Bad Request: {exception.GetType().Name}", // human-readable summary of the problem type
Detail = message, //detailed explanation specific to the problem
Status = status,
Instance = httpContext.Request.GetEncodedUrl(),
Type = exception.HelpLink
};
var errorcontext = new ProblemDetailsContext()
{
HttpContext = httpContext,
ProblemDetails = problemDetails,
//Exception = exception,
AdditionalMetadata = null
};
httpContext.Response.Clear();
httpContext.Response.StatusCode = status;
var written = await _problemDetailsService.TryWriteAsync(errorcontext);
if (!written)
{
try
{
await httpContext.Response.WriteAsJsonAsync<ProblemDetailsContext>(errorcontext);
written = true;
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
written = false;
}
}
return written;
}
}
This is my Program.cs
:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Net.Http;
using Microsoft.AspNetCore.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Add services to the container.
builder.Services.AddExceptionHandler<ProblemDetailsScratchPad.GlobalExceptionHandler>(); // Using a custom exception handler that converts the exception into a Problem Details
builder.Services.AddProblemDetails(); // required for using the exception handler with the custom problem details exception handler code because it adds the services required for Problem Details
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseExceptionHandler(opt => { }); //adding the custom GlobalExceptionHandler to the app's pipeline
app.UseAuthorization();
app.MapControllers();
app.Run();
And this is the implementation for my WeatherForecastController
(not that calling either the post or get will throw the exception to be handled by the custom exception handler)
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet("GetWeatherForecast")]
public ActionResult<IEnumerable<object>> GetWeatherForecast()
{
throw new ArgumentException("Please provide a valid value for a week day: between 0 and 6");
}
[HttpPost("PostWeatherForecastDTO")]
public ActionResult<IEnumerable<object>> PostWeatherForecastDTO()
{
throw new Exception("Please provide a valid value for a week day: between 0 and 6");
}
}
Managed to reproduce your problem with Swagger's "Try it out/Execute". It defaulted to use accept header plain/text
. Postman doesn't set it, so that's why it works there. You can try setting the "media type" to application/json
in the Swagger UI before sending the request. It produced the expected response for me.
The accept header mismatch would also explain why ProblemDetailsService.TryWriteAsync
fails - it is expected to output json, not plain-text.
You can take a look at this open github issue detailing your problem for workarounds: