Trapping application errors
.NET .NET Core ASP.NET Core Best Practices C# Visual Studio Web API

How to Handle Errors within a .NET Core Minimal Web API

Welcome to today’s post.

In today’s post I will be discussing how to use error handling within a .NET Core Minimal Web API application.

In previous posts I discussed error and status codes within .NET Core API applications and how to configure error handling centrally within .NET Core API applications.

I will be showing how the following methods of error handling and response are dealt within a minimal Web API:

  1. The configuration and response of unhandled errors.
  2. The configuration and response of handled errors.
  3. Differences between handling errors in development and production environments.
  4. Differences between configuring error handling in a development and a production environment.
  5. Use of status code pages.
  6. Customizing error response output with problem details.

In the first section, I will be showing how to differentiate between handling errors in development and production environments.

Differences between handling errors in development and production environments

In a development environment, we do not need to capture and reformat exceptions as we will need the exception and possibly the stack trace to troubleshoot issues that occur during development. In this case, we use the developer exception page, which is added to the application middleware pipeline using:

app.UseDeveloperExceptionPage();

Below is a typical developer exception page that appears, when we run the API application in the Development environment following an unhandled exception with no content:

A stack trace for the same error is shown below:

In a non-development, or a production environment, we will need to enable the exception handler middleware, which is used to redirect errors to an error page. This is done as shown:

app.UseExceptionHandler("/Error"); 

Below is the code used to achieve the above default error handling and custom error handling:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
else
{
    app.UseDeveloperExceptionPage();
}
…

Note: The most important purpose behind handling error responses in with formatted output is to avoid showing the stack trace to end-users, which could pose a security threat. That is why, in a non-development environment (including production), we should NEVER output errors with a raw exception or a stack trace. We can do this in the development environment for debugging purposes as the output is in a local environment.

In the next section, I will cover the configuration of the response of unhandled errors.

The configuration and response of unhandled errors

To test an unhandled error, we add the following handler:

// test an unhandled exception
app.Map("/testexception", ()
    => { throw new InvalidOperationException("An unhandled exception!"); }
);

When the run the web API application, then browse to the following URL (the port will vary in your own environment):

https://localhost:7013/testexception

The response in the request headers has a status code of 500, with content type of text/html, is shown below:

The general request header and response headers are:

Request URL: https://localhost:7013/testexception
Request Method: GET
Status Code: 500 
Remote Address: [::1]:7013
Referrer Policy: strict-origin-when-cross-origin
cache-control: no-cache,no-store
content-type: text/html
date: Sat, 18 Mar 2023 14:08:52 GMT
expires: -1
pragma: no-cache
server: Kestrel

In the next section, I will show how status pages are used to format errors.

Use of status code pages

In a production environment, we would prefer to format error responses without the potentially sensitive details of the exception massage and the stack trace. The error response uses the Problem Details structure, which displays the error in a standard format that includes the following fields:

type:

title:

status:

We can enable the problem details in the minimal API as shown:

..

builder.Services.AddProblemDetails();

..

var app = builder.Build();

Then add the middleware:

app.UseStatusCodePages();

When executing the API in the Production environment, we will see the browser response show the unhandled error display in a more user-friendly format as shown:

The header request will show with a content type as application/problem+json:

The general request header and response headers are:

Request URL: https://localhost:7013/testexception
Request Method: GET
Status Code: 500 
Remote Address: [::1]:7013
Referrer Policy: strict-origin-when-cross-origin
cache-control: no-cache,no-store
content-type: application/problem+json
date: Sat, 18 Mar 2023 14:08:52 GMT
expires: -1
pragma: no-cache
server: Kestrel

Notice the response in the request headers has a status code of 500, with content type of application/problem+json.

The structure of the error response is compliant with the RFC7231 standard that includes the Problem Details structure:

{
    "type":"https://tools.ietf.org/html/rfc7231#section-6.6.1",
    "title":"An error occurred while processing your request.",
    "status":500
}

In the next section, I will show how to configure the response of handled errors.

The configuration and response of handled errors

There are built-in results for the error and status codes in the 2xx range and between 400-500.

The helpers for these status codes exist in the Microsoft.AspNetCore.Http.Results class.

I will explore the response that we get from the 404 error, which is the not found error. The API response enumeration in this case is Results.NotFound().

In the API method for the endpoint:

/bookitems/{i}

We return a Results.NotFound() when a record of the given identifier cannot be located within the database.

public static async Task<Microsoft.AspNetCore.Http.IResult> BookItem(int id, BookLoanDb db)
{
    var book = await db.Books.FindAsync(id);
    if (book is null)
        return Results.NotFound();
    if (book is BookViewModel)
        return Results.Ok(book);
    return Results.NotFound();
}

To test the above, I browsed to the URL with an id parameter that would yield no matching record:

https://localhost:7013/bookitems/333

The response in the browser would be:

This corresponds to the not-found error. The headers details within the browser developer tools shows no content.

When the status code page middleware is enabled, the following problem details compliant response content shows in the browser:

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404}

The header details in the developer tools shows the following:

Request URL: https://localhost:7013/bookitems/333
Request Method: GET
Status Code: 404 
Remote Address: [::1]:7013
Referrer Policy: strict-origin-when-cross-origin
content-type: application/problem+json
date: Sat, 18 Mar 2023 13:55:53 GMT
server: Kestrel

In all cases, for 4xx or 5xx exception errors, when problem details and status code pages are enabled, the content type in the response header shows as application/problem+json

In the next section, I will show the differences between configuring error handling in development and production environments.

Differences between configuring error handling in a development and a production environment

In a minimal Web API, we first setup error handling by including the problem details service and exception handling middleware:

..
builder.Services.AddProblemDetails();
..
var app = builder.Build();
..
app.UseExceptionHandler();
..

We then setup developer exception handling for a development environment and status code pages for non-development (production) environments as shown:

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

    app.UseExceptionHandler(config =>
        config.Run(async context
            => await Results
                .Problem()
                .ExecuteAsync(context)
        )
    );
}
..

To simulate a development environment, set the “ASPNETCORE_ENVIRONMENT”  key under the “environmentVariables” element in appLaunch.settings to “Development”:

"profiles": {
    "BookLoan.CatalogMin.API": {
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
        "applicationUrl": "https://localhost:7013;http://localhost:5013",
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        }
    },
    ..

To simulate a production environment, set the “ASPNETCORE_ENVIRONMENT”  key under the “environmentVariables” element in appLaunch.settings to “Production”:

  "profiles": {
    "BookLoan.CatalogMin.API": {
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
        "applicationUrl": "https://localhost:7013;http://localhost:5013",
        "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Production"
        }
    },
    ..

In the next section, I will show how to customize error responses with problem details.

Customizing Error Responses with Problem Details

To customize error responses using problem details, we will need to implement the following changes to our minimal API application:

  1. Enable the Status Code Pages middleware, UseStatusCodePages().
  2. Amend any API method(s) to trap error conditions and add Feature types to the request, returning into the HTTP error response result.
  3. Add a delegate handler to CustomizeProblemDetails() to format the error Feature within the HTTP response.

With the first task, I showed how to enable the status pages middleware earlier.

With task two, trapping and add custom error features to the HTTP response, can be done in the following API method CreateReview() where we create a book review. I trap three erroneous cases, a rating that exceeds the allowable range (1,2,3,4,5), an empty or null comment, and an empty or null reviewer. 

In each case I create an object of type BookReviewErrorFeature, set the ReviewError review error property, set the Features property of the HTTP context, then return the error response.

public static async Task<Microsoft.AspNetCore.Http.IResult> CreateReview(
    HttpContext context, BookReviewModel review, BookLoanDb db)
{
    int[] ratings = { 1, 2, 3, 4, 5 };

    if (!ratings.Contains(review.Rating))
    {
        var errorType = new BookReviewErrorFeature
        {
            ReviewError = BookReviewErrorType.RatingOutOfRange
        };
        context.Features.Set(errorType);
        return Results.BadRequest();
    }

    if (String.IsNullOrEmpty(review.Comment))
    {
        var errorType = new BookReviewErrorFeature
        {
            ReviewError = BookReviewErrorType.CommentNotSpecified
        };
        context.Features.Set(errorType);
        return Results.BadRequest();
    }

    if (String.IsNullOrEmpty(review.Reviewer))
    {
        var errorType = new BookReviewErrorFeature
        {
            ReviewError = BookReviewErrorType.ReviewerNotSpecified
        };
        context.Features.Set(errorType);
        return Results.BadRequest();
    }

    db.Reviews.Add(review);
    await db.SaveChangesAsync();
    return (Microsoft.AspNetCore.Http.IResult)Results.Created($"/bookreview/{review.ID}", review.ID);
}

For reference, the classes I created for the error features are shown below:

class BookReviewErrorFeature
{
    public BookReviewErrorType ReviewError { get; set; }
}

enum BookReviewErrorType
{
    RatingOutOfRange = 1,
    CommentNotSpecified = 2,
    ReviewerNotSpecified = 3
}

Also note that I passed the instance of the HTTP context and other local parameter instances from the main program to the mapped API method, which can either be in the Program.cs code file like this:

app.MapPost("/bookreviews", (HttpContext context, BookReviewModel review, BookLoanDb db) => 
{
    .. API code here ..
});

or in its own static handler method, I can map the local instances of the required parameters like so:

app.MapPost("/bookreviews", 
    (HttpContext context, BookReviewModel review, BookLoanDb db) => 
        BookLoanMethods.CreateReview(context, review, db)
);

Note: Unlike API applications that have the transient service instances from the service collection injected into our services and controllers, with the minimal API, we pass local instances of parameters into the mapped API methods. In the above case, I did not need to inject the IHttpContextAccessor service to access an instance of HttpContext. I discussed this in a previous post on using HttpContext in .NET Core applications.

With the final task, we add a delegate handler to CustomizeProblemDetails() to format the Feature within the HTTP response. This is done by configuring the AddProblemDetails() service as shown:

builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = (context) =>
    {
        var bookreviewErrorFeature = context.HttpContext
            .Features
            .Get<BookReviewErrorFeature>();
        if (bookreviewErrorFeature is not null)
        {
            (string Detail, string Type) details = bookreviewErrorFeature.ReviewError;
            switch
            {
                BookReviewErrorType.RatingOutOfRange =>
                (
                    "Rating is out of range.",
                    "Rating should be in the range [1,2,3,4,5]."
                ),
                BookReviewErrorType.CommentNotSpecified =>
                (
                    "Comment is not specified.",
                    "Required field."
                ),
                _ =>
                (
                    "Reviewer is not specified.",
                    "Required field."
                )
            };

            context.ProblemDetails.Type = details.Type;
            context.ProblemDetails.Title = "Bad Input";
            context.ProblemDetails.Detail = details.Detail;
        }
    }
);

Note: To be able to use the Problem Details middleware and the capability to be able to customize the error response output with a delegate handler like we have done above, we will need to be using .NET (Core) 7.0. In .NET (Core) 6.0, we don’t have the ProblemDetails service middleware even though we can use the StatusCodePages middleware to produce default formatted outputs for error responses in non-development environments.  

If we run the API application with Swagger enabled, we can run a quick test to debug and view the response JSON output. Below is the input through the Swagger interface:

The response after posting the following payload with some erroneous parameter:

{
  "bookID": 1,
  "comment": "It's a great read!",
  "reviewer": "Andy H",
  "rating": 6,
  "heading": "Such a great book!",
  "approver": "",
  "isVisible": true,
  "dateReviewed": "2023-03-19T15:23:17.326Z",
  "dateCreated": "2023-03-19T15:23:17.327Z",
  "dateUpdated": "2023-03-19T15:23:17.327Z"
}

Is shown below:

The response body in JSON format satisfies the Problem standard response format:

{
  "type": "Rating should be in the range [1,2,3,4,5].",
  "title": "Bad Input",
  "status": 400,
  "detail": "Rating is out of range."
}

You will also notice that the values within the response header are identical to the outputs we saw earlier when we enabled the status code pages:

content-type: application/problem+json 
 date: Tue,21 Mar 2023 10:22:04 GMT 
 server: Kestrel

When we run the same HTTP POST request using POSTMAN, we get the same response headers:

We also get same response body in the Problem format:

With the above scenarios, I have shown how to configure a minimal web API application to output error responses for development and non-development environments. I have also showed how to output default status code page outputs within a production environment. I have also showed how to customize the problem details response outputs for any type of handled error that occurs within our web API methods. I also stressed the reasons why we must format error responses in non-development environments.

That is all for today’s post.

I hope you have found this post useful and informative.

Social media & sharing icons powered by UltimatelySocial