Search code examples
c#asp.net-core.net-core.net-7.0asp.net-core-middleware

Global Exception Handling with .NET Core 7


I am working on a .NET Core 7 application where I want to capture any unexpected error and send back specific details along with a 500 status code. To accomplish this, I implemented a middleware instance that is supposed to run a try/catch, but when I trigger an unexpected exception, the catch block never actually catches the error and the raw error (along with stack trace) is returned in a text/plain response (with a 500 server error).

Here is the Invoke method within the middleware:

public virtual async Task Invoke(HttpContext context)
{
    try
    {
        await this.RequestDelegate.Invoke(context);
    }
    catch (Exception ex)
    {
        // check whether we've started the response yet
        if (!context.Response.HasStarted)
        {
            // since we haven't, create the response model
            var model = new UnhandledExceptionModel(ex);
            var response = new ObjectResult(model)
                    {
                        StatusCode = StatusCodes.Status500InternalServerError
                    };

            context.Response.ContentType = "application/json";
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

            await context.Response.WriteAsJsonAsync(response).ConfigureAwait(false);
        }
        else
            throw;
    }
}

Via debugging, I've confirmed that this line:

await this.RequestDelegate.Invoke(context)

does run, so I know the middleware has been loaded, but the associated catch never gets hit. I also tried using an ExceptionHandler, but that also didn't hit any breakpoints and the response wasn't JSON.

Here's what it looked like:

app.UseExceptionHandler(a => a.Run(async context =>
        {
            var error = context.Features.Get<IExceptionHandlerFeature>().Error;

            var model = new UnhandledExceptionModel(error);
            context.Response.ContentType = "application/json";
            context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

            await context.Response.WriteAsJsonAsync(model);
        }));

I assume I'm missing some small step here, but I can't seem to figure out where it's going wrong. Any suggestions?

UPDATE 1

The middleware is called ExceptionHandlingMiddleware and is currently the only middleware configured, but it inherits from a base class called BaseMiddleware. Here's the full definition:

public class ExceptionHandlingMiddleware : BaseMiddleware
{
    public ExceptionHandlingMiddleware(RequestDelegate next)
        : base(next)
    {
    }
}

Definition of BaseMiddleware:

/// <summary>
/// Represents the base class for middleware implementations
/// </summary>
public abstract class BaseMiddleware : IMiddleware
{
    /// <summary>
    /// Represents the delegate responsible for the request.
    /// </summary>
    public RequestDelegate RequestDelegate { get; }

    /// <summary>
    /// Instantiates an instance of this middleware with
    /// a corresponding RequestDelegate.
    /// </summary>
    /// <param name="next"></param>
    public BaseMiddleware(RequestDelegate next)
    {
        this.RequestDelegate = next;
    }

    /// <summary>
    /// Represents the method to call when utilizing
    /// a middleware solution.
    /// </summary>
    /// <param name="context">The HttpContext associated with the request.</param>
    /// <returns>A Task.</returns>
    public virtual async Task Invoke(HttpContext context)
    {
        try
        {
            await Task.Run(() => this.RequestDelegate.Invoke(context));
        }
        catch (Exception ex)
        {
            //check whether we've started the response yet
            if (!context.Response.HasStarted)
            {
                //since we haven't, create the response model
                var model = new UnhandledExceptionModel(ex);
                var response = new ObjectResult(model)
                {
                    StatusCode = StatusCodes.Status500InternalServerError
                };

                context.Response.ContentType = "application/json";
                context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

                await context.Response.WriteAsJsonAsync(response).ConfigureAwait(false);

                //send the 500 error back as our model
                //var routeData = context.GetRouteData();
                //var actionDescriptor = new ActionDescriptor();
                //var actionContext = new ActionContext(context, routeData, actionDescriptor);
                //await response.ExecuteResultAsync(actionContext);
                //return;
            }
            else
                throw;
        }
    }
}

The IMiddleware interface just exposes two properties:

/// <summary>
/// Represents the core structure of a middleware provider.
/// </summary>
public interface IMiddleware
{
    /// <summary>
    /// Represents the delegate responsible for the request.
    /// </summary>
    RequestDelegate RequestDelegate { get; }

    /// <summary>
    /// Represents the method to call when utilizing
    /// a middleware solution.
    /// </summary>
    /// <param name="context">The HttpContext associated with the request.</param>
    /// <returns>A Task.</returns>
    Task Invoke(HttpContext context);
}

Inside Startup.cs there is the standard Configure method - here's my full one:

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    //enables callers from anywhere
    app.UseCors("AllowAnyOrigin");

    app.UseMiddleware<ExceptionHandlingMiddleware>();

    //setup an exception handler so even with hard exceptions,
    //we get a consistent response with a model.
    //this handler is never called
    app.UseExceptionHandler(a => a.Run(async context =>
    {
        var error = context.Features.Get<IExceptionHandlerFeature>().Error;

        var model = new UnhandledExceptionModel(error);
        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

        await context.Response.WriteAsJsonAsync(model);
    }));

    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "API Docs");
        c.EnableTryItOutByDefault();

    });

    app.UseSession()
       .UseHttpsRedirection()
       .UseRouting()
       .UseAuthorization()
       .UseEndpoints(endpoints =>
       {
           endpoints.MapControllers();
       });

    //attach the HttpContext so the backend can track things like caller IP address
    var httpContext = app.ApplicationServices.GetRequiredService<IHttpContextAccessor>();
    Core.HttpContext.Configure(httpContext);
}

My example for app.UseExceptionHandler came from an existing SO post: https://stackoverflow.com/a/68267750/84839, but unfortunately it never gets called. Out of curiosity - could this be an issue with using a n-tier setup since the exception is hit inside that layer? I'm not doing anything special (no async, no threading and no Task.Run instances), but maybe because I'm not using async methods it's not able to snag it?

I'm going to try and play with some samples to see if I can get a smaller version of this running that's reproducible.

UPDATE 2

I tried directly throwing an exception right inside the endpoint handler, but the response was still the raw stack trace. I also commented out app.UseDeveloperExceptionPage, but all that did was ensure I received the 500 server error with no response. Here's the endpoint I tried that on:

[HttpGet("{id}")]
public IActionResult GetById(long id)
{
    throw new ApplicationException();
}

Solution

  • It took some creative digging (thanks to the numerous tips posted), but I finally tracked down what was going on! The relevant code plays into app.UseEndpoints - for whatever reason, adding your middleware after this won't allow it to be called, so no breakpoints were being hit and it wasn't executing. Once I put it above that line, breakpoints were hit and I could handle the exceptions as expected. Here's the final section from within Configure in Startup.cs:

    //enables callers from anywhere
    app.UseCors("AllowAnyOrigin");
    
    //app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "TMAC Lawn Equipment System API");
        c.EnableTryItOutByDefault();
    
    });
    
    app.UseSession();
    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthorization();
    
    //put middleware here, before UseEndpoints to ensure it gets called
    app.UseMiddleware<ExceptionHandlingMiddleware>();
    
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });