Search code examples
c#asp.net-coreerror-handlingswagger-ui.net-8.0

Custom Exception Handler in API Controller Failing in C# and .NET 8


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");

    }
}

Solution

  • 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: