Search code examples
c#asp.net-core

ASP.NET Core Custom IExceptionHandler doesn't catch exceptions


I'm trying to create a global exception handler for my Web API using IExceptionHandler. However my handler is not catching any exception.

public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{       
    public ValueTask<bool> TryHandleAsync(HttpContext context, Exception ex, CancellationToken ct)
    {
        logger.LogError(ex, "Controller error occured");

        ErrorResponse errors = new()
        {
            Errors = [new ApiError(-1, "Internal Server Error")]
        };
        string jsonRes = JsonSerializer.Serialize(errors);

        context.Response.BodyWriter.Write(Encoding.UTF8.GetBytes(jsonRes));

        return ValueTask.FromResult(true);
    }
}

Program.cs

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
using Asp.Versioning;
using API.Services;
using NRediSearch;
using StackExchange.Redis;
using API.Authentication.ApiKeyAuthenticaiton;
using Microsoft.OpenApi.Models;
using API;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddEnvironmentVariables();

IConfiguration configuration = builder.Configuration;

// Add services to the container.

builder.Services.AddControllers();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1);
    options.ReportApiVersions = true;
    options.AssumeDefaultVersionWhenUnspecified = false;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader());
}).AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'V";
    options.SubstituteApiVersionInUrl = true;
});

builder.Services.AddSwaggerGen(options =>
{
    options.EnableAnnotations();
    options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
    {
        Title = "API",
        Version = "v1"
    });

    options.AddSecurityDefinition(ApiKeyAuthenticationDefaults.AuthenticationScheme, new()
    {
        Name = "x-api-key",
        Description = "API key authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey
    });

    options.AddSecurityRequirement(new()
    {
        {
            new OpenApiSecurityScheme()
            {
                Reference = new OpenApiReference()
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = ApiKeyAuthenticationDefaults.AuthenticationScheme
                },
                Name = ApiKeyAuthenticationDefaults.AuthenticationScheme,
                In = ParameterLocation.Header
            },
            new List<string>()
        }
    });
});

builder.Services.Configure<RouteOptions>(options =>
{
    options.LowercaseUrls = true;
});

string redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? throw new Exception("No Redis connection string found");

builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(redisConnectionString));
builder.Services.AddSingleton(serviceProvider =>
{
    IConnectionMultiplexer multiplexer = serviceProvider.GetRequiredService<IConnectionMultiplexer>();
    IDatabase db = multiplexer.GetDatabase();
    Client client = new("queueIdx", db);

    return client;
});
builder.Services.AddSingleton<IRedisService, RedisService>();
builder.Services.AddScoped<IQueueManager, QueueManager>();
builder.Services.AddSingleton<QueuesPoolService>();
builder.Services.AddSingleton(configuration);
builder.Logging.AddConsole();

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = ApiKeyAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = ApiKeyAuthenticationDefaults.AuthenticationScheme;
})
    .AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(ApiKeyAuthenticationDefaults.AuthenticationScheme, options =>
    {
        options.ApiKey = configuration["ApiKey"] ?? throw new Exception("No API key was configured");
    });

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

// Set up Redis DB

IRedisService redis = app.Services.GetRequiredService<IRedisService>();
await redis.CreateIndexAsync();
await redis.ConfigureAsync();

app.MapControllers();

app.Run();

I am purposefully throwing an error from a controller action and it is not being caught. The debugger breakpoint is not hit at all.

public async Task<IActionResult> GetCurrentPlayerQueue(long userId)
{
    throw new Exception("test");
}

The API follows the default exception handling behavior: it returns the full exceptions details in development environment and an empty response body in production env. I expect my API to use GlobalExceptionHandler and write the errors to the response body.


Solution

  • I found the fix: for controller based Web APIs, you have to create a Controller and add a no-method action to bind UseExceptionHandler() to.

    [ApiController]
    [Route("error")]
    public class ErrorController : Controller
    {
        public IActionResult Index() =>
            Problem();
    }
    
    app.UseExceptionHandler("/error");
    

    Source: https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors?view=aspnetcore-8.0#exception-handler