ASP.NET Core 2.2 - Error and Exception Handling

Ken Haggerty
Created 02/28/2019 - Updated 09/29/2020 20:52

This is a follow-up of my previous article Extending the EmailSender to Include Send Admin Email. I will assume you have created a new ASP.NET Core 2.2 Razor Pages project and have implemented a functional EmailSender. The new ASP.NET Core Web Application template has an Error page, detailed error handling for Development and basic error handling for production. To demonstrate these behaviors, I will deliberately throw an exception.

Edit Index.cshtml.cs > OnGet:
public void OnGet()
{
    throw new InvalidOperationException("Test On Get Exception.");
}

Resolve the namespace issue with using System;. Build and run, which displays the developer detailed error page.

Developer Error Page

This is the easiest way to display the production error page.

Edit Startup.cs > Configure:
if (env.IsDevelopment())
{
    //app.UseDeveloperExceptionPage();
    //app.UseDatabaseErrorPage();
    app.UseExceptionHandler("/Error");
}
else
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

Build and run, which displays the production error page.

Use Exception Handler

You should edit the Error.cshtml to display a user-friendly message. I include a statement "Support has been notified with detailed information about the error that occurred. Please send an email to support@kenhaggerty.com with any description of how we can recreate the error."

KenHaggerty.Com Error Page

Comment the throw exception from Index.cshtml.cs > OnGet, we will use it later. Build and run displays the index page. Enter an invalid path in the address bar like /notrealpage, which displays a not very friendly page.

Not Found Page

You can redirect bad status codes to the Error page.

Edit Startup.cs > Configure, add app. UseStatusCodePagesWithReExecute ("/Error/{0}");:
if (env.IsDevelopment())
{
    //app.UseDeveloperExceptionPage();
    //app.UseDatabaseErrorPage();
    app.UseExceptionHandler("/Error");
    app.UseStatusCodePagesWithReExecute("/Error/{0}");
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseStatusCodePagesWithReExecute("/Error/{0}");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}
Edit Error.cshtml.cs > OnGet:
public void OnGet(int? statusCode = null)
{
    if (statusCode == null)
        statusCode = HttpContext.Response.StatusCode;

    RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
Edit Error.cshtml, add "{statusCode?}" to the page directive:
@page "{statusCode?}"

Now we can collect more information, then log or send an admin email (see Extending the EmailSender to Include Send Admin Email) by injecting the IEmailSender.

Edit Error.cshtml.cs:
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public class ErrorModel : PageModel
{
    private readonly IEmailSender _emailSender;

    public ErrorModel(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public string RequestId { get; set; }

    public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);

    public async Task OnGetAsync(int? statusCode = null)
    {
        if (statusCode == null)
            statusCode = HttpContext.Response.StatusCode;

        RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;

        string uaString = HttpContext.Request.Headers["User-Agent"].ToString();

        string ipAnonymizedString = HttpContext.Connection.RemoteIpAddress.AnonymizeIP();

        var uid = "Unknown";
        if (User.Identity.IsAuthenticated)
        {
                uid = User.FindFirst(ClaimTypes.NameIdentifier).Value;
        }

        var feature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>();

        StringBuilder sb = new StringBuilder($"An error has occurred on {HttpContext.Request.Host}. \r\n \r\n");
        sb.Append($"RequestId = {RequestId} \r\nStatusCode = {statusCode.ToString()} \r\n");

        sb.Append($"OriginalPath = {feature?.OriginalPath} \r\n \r\n");

        sb.Append($"Path = {Request.Path}. \r\n \r\n");

        var exception = HttpContext.Features.Get<IExceptionHandlerFeature>();
        if (exception != null)
        {
            sb.Append($"Error Message = {exception.Error.Message}. \r\n");
            sb.Append($"Error Source = {exception.Error.Source}. \r\n");

            if (exception.Error.InnerException != null)
                sb.Append($"Inner Exception = {exception.Error.InnerException.ToString()}. \r\n");
            else
                sb.Append("Inner Exception = null. \r\n");

            sb.Append($"Error StackTrace = {exception.Error.StackTrace}. \r\n");
        }

        await _emailSender.SendAdminEmailAsync($"Error on {HttpContext.Request.Host}.", sb.ToString(), uaString, ipAnonymizedString, uid);

    }
}

Resolve the namespace issues. Be careful and make sure you use using <YOURPROJECT>.Services; instead of using Microsoft.AspNetCore.Identity.UI.Services;. Build and run to verify there are no normal operation issues. Test the page not found and exception error to verify the admin gets notified by email.

Of course, we try to develop and test our code to prevent exceptions in production but getting notified of exceptions when they do occur is critical. I had a data foreign key issue in production which I must have resolved in development. While testing a new feature in production, I encountered the error and was redirected to the error page. I waited for the admin email to get the details, but I never received it. Let's create a test which demonstrates this issue.

Add this form to Index.cshtml:
<div class="row">
    <div class="col-6">
        <form method="post">
            <button type="submit" class="btn btn-primary">Post Exception</button>
        </form>
    </div>
</div>
Edit Index.cshtml.cs, add OnPost
public void OnPost()
{
    throw new InvalidOperationException($"Test Post Exception.");
}

Put breakpoints on the throw exception and on the OnGet in Error.cshtml.cs then build, run and test. The breakpoint on the throw exception gets hit but the breakpoint on the OnGet does not. The error page is displayed but the email is never sent. If you look at the definition of UseExceptionHandler, the summary states "The request will not be re-executed if the response has already started". To trap the exception in the response we need to use middleware.

Add an ExceptionEmailerMiddleware class and extension to the Services folder:
public class ExceptionEmailerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IEmailSender _emailSender;

    public ExceptionEmailerMiddleware(RequestDelegate next, IEmailSender emailSender)
    {
        _next = next;
        _emailSender = emailSender;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            string uaString = context.Request.Headers["User-Agent"].ToString();
            string ipAnonymizedString = context.Connection.RemoteIpAddress.AnonymizeIP();
            var uid = "Unknown";
            if (context.User.Identity.IsAuthenticated)
            {
                uid = context.User.FindFirst(ClaimTypes.NameIdentifier).Value;
            }

            StringBuilder sb = new StringBuilder($"An error has occurred on {context.Request.Host}. \r\n \r\n");
            sb.Append($"Path = {context.Request.Path} \r\n \r\n");
            sb.Append($"Error Message = {ex.Message} \r\n");
            sb.Append($"Error Source = {ex.Source} \r\n");

            if (ex.InnerException != null)
                sb.Append($"Inner Exception = {ex.InnerException.ToString()} \r\n");
            else
                sb.Append("Inner Exception = null \r\n");

            sb.Append($"Error StackTrace = {ex.StackTrace} \r\n");

            await _emailSender.SendAdminEmailAsync($"Error on {context.Request.Host}.", sb.ToString(), uaString, ipAnonymizedString, uid);

            throw new InvalidOperationException($"Recorded By Middleware: {ex.Message}");
                
        }
    }
}

public static class ExceptionEmailerMiddlewareExtensions
{
    public static IApplicationBuilder UseExceptionEmailer(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<ExceptionEmailerMiddleware>();
    }
}
Edit Startup.cs > Configure, add the IEmailSender parameter and the app.UseExceptionEmailer();:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, IEmailSender emailSender)
{
    if (env.IsDevelopment())
    {
        //app.UseDeveloperExceptionPage();
        //app.UseDatabaseErrorPage();
        app.UseExceptionHandler("/Error");
        app.UseStatusCodePagesWithReExecute("/Error/{0}");
        app.UseExceptionEmailer();
    }
    else
    {
        app.UseExceptionHandler("/Error");
        app.UseStatusCodePagesWithReExecute("/Error/{0}");
        app.UseExceptionEmailer();
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseCookiePolicy();

    app.UseAuthentication();

    app.UseMvc();
}

Add another breakpoint on string uaString = context.Request.Headers["User-Agent"].ToString(); after the catch in ExceptionEmailerMiddleware > InvokeAsync. Build, run and test.

The breakpoints on throw exception and in the middleware get hit. The admin receives a detailed email describing the exception and the end user sees a friendly error page. Now let's go back and test an exception during the request. Uncomment the throw exception in Index.cshtml.cs > OnGet. Build and run.

The admin receives two emails, the first with details and the second stating the exception has already been recorded. That is why I used throw new InvalidOperationException($"Recorded By Middleware: {ex.Message}"); rather than just throw; or throw ex;.

Don't forget to re-enable the detailed developer error handling.

Edit Sartup.cs > Configure:

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseDatabaseErrorPage();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseStatusCodePagesWithReExecute("/Error/{0}");
    app.UseExceptionEmailer();
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}
Update 03/13/2019

The cut and paste results were confusing. I found and corrected the ExceptionEmailerMiddleware type in the ExceptionEmailerMiddleware.cs > ExceptionEmailerMiddlewareExtensions > UseExceptionEmailer > UseMiddleware was not properly escaped.

Update 09/29/2020

Small screen formatting.

Comment Count = 2

DPGumby
DPGumby
Posted 09/27/2020 11:27
Member Since 09/21/2020

This is a very good article.

Ken Haggerty
Ken Haggerty *
Posted 09/27/2020 16:42
Member Since 01/04/2019

Thank you for registering and commenting. I'm glad you found this article worthy of the effort.

Please log in to comment.

Login Register
Logged in users receive web notifications.
Web Notifications