Tutorials / Build a REST API with C# and .NET · Lesson 21 of 27

On this page

    Errors the Professional Way

    Reading books showed two errors arriving in a tidy JSON shape that we never wrote: a 404 for a book that does not exist, and a 400 for an id that is not a number. That shape has a name, Problem Details, and it is the standard way to describe an error in JSON. This lesson explains it, and deals with the error nobody planned: an exception (see exceptions and stack traces) that nothing caught.

    C# does not force a developer to report errors in any particular way, so many invent their own: return null, return a sentence, or wrap every answer in a success flag. Each one makes life hard for the person who calls the API. There is a standard way, built into ASP.NET Core, and this lesson is about following it instead of inventing.

    Invented errors, and the standard way The invented ways: every endpoint is different null: status 204 a sentence: status 200 a success flag: status 200 error text: status 200 Frontend four ways to read an error, and the status says "success" The standard way: one shape for all NotFound(): status 404 bad input: status 400 an exception: status 500 Problem Details read the status, then one shape

    Our goal: learn the standard way for an API to say that something went wrong, and use it for every case: not found, bad input, and an unexpected crash. The people who call the API then have only one thing to learn.

    Here is the route:

    1. See the ways people invent, and meet the standard.
    2. Sort the cases: errors we expect and errors we do not. Then go through the fields of the standard shape, called Problem Details.
    3. Cause a crash (a 500) and see what comes out by default.
    4. Fix it with two lines, so a crash also answers in the standard shape.
    5. A security point: why a user must never see the details of a crash, and where the details go instead.
    6. Write a handler class of our own, for the wording and the log.
    7. Close one more gap: errors that arrive with no body at all.
    8. Test the handler, and finish with a short rule book.

    This lesson leaves out the list of validation errors for a bad body (create a book), the 401 and 403 answers of login, custom exception classes, and the log-reading tools that real teams use.

    Where we are

    This is the solution from the end of reading books. We touch these files:

    BookShop/
      BookShop.slnx
      Directory.Build.props
      Directory.Packages.props
      src/
        BookShop.Api/
          Program.cs                         <- changes (the error handling is switched on)
          appsettings.json                   <- changes for a moment (to cause an error), then changed back
          GlobalExceptionHandler.cs          <- NEW
          Controllers/
            BooksController.cs
        BookShop.Data/
      tests/
        BookShop.Data.Tests/
        BookShop.Api.Tests/
          FakeBookService.cs
          BooksControllerTests.cs
          FakeLogger.cs                      <- NEW
          GlobalExceptionHandlerTests.cs     <- NEW
    

    Run every command from the solution folder, BookShop/. No project is added, so there is no solution map this time.


    The ways people invent

    Nothing in C# says how an API must report an error, so every team could invent its own. Here are the habits you will meet most often. They are the top row of the picture at the top of the lesson, and they are shown to warn you. Do not add them to the project. We ran each one on a book that does not exist (id 99), to see what the client really receives.

    1. Return null

    [HttpGet("null/{id}")]
    public async Task<Book?> ReturnsNull(int id)
    {
        return await _bookService.GetByIdAsync(id);
    }
    

    The service says null for "no such book" (see the books service), and this controller passes it straight on.

    HTTP/1.1 204 No Content
    

    204 No Content means "it worked, and there is nothing to send". The client is told the request succeeded for a book that does not exist, and it cannot tell "missing" from "empty" from "something broke".

    2. Return a sentence

    if (book == null)
    {
        return "Book not found";
    }
    
    HTTP/1.1 200 OK
    Content-Type: text/plain; charset=utf-8
    
    Book not found
    

    Status 200 means success, and the body is sometimes a book in JSON and sometimes plain text. To find out which, the frontend has to read the text and guess (does it start with the word "Error"?). Change the wording one day, and the frontend breaks without a warning.

    3. Wrap every answer

    return new { success = false, error = "Book not found" };
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    
    {"success":false,"error":"Book not found"}
    

    This one is better, since it is JSON, and some teams use it on purpose. It still has faults. The status says 200, so everything that reads status codes (browsers, caches, monitoring, other tools) is told it succeeded. The shape is this team's own invention, so every client must be taught it. And every good answer has to be wrapped as well.

    4. A try/catch in every action

    try
    {
        // find the book, return it
    }
    catch (Exception ex)
    {
        return "Error: " + ex.Message;
    }
    

    This is the most common habit of all. Here is what it sends when the database is broken (we break one on purpose below):

    HTTP/1.1 200 OK
    Content-Type: text/plain; charset=utf-8
    
    Error: SQLite Error 1: 'no such table: Books'.
    

    Three faults in one answer. A failure is reported as a success (200). It leaks a message from inside the database library. And the code is copied into every action, so one action that forgets it behaves differently from the rest.

    The standard way

    Nobody has to invent any of this. The web already has the pieces, and ASP.NET Core builds them in. The standard covers a good answer and a bad one:

    Status Body
    Success 2xx The data itself: the book, or the list of books. Nothing wrapped around it.
    Failure 4xx or 5xx Problem Details: one standard shape for every error.

    This is the whole failure half. It is the body we got for a book that does not exist, and every other error arrives in the same shape:

    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
      "title": "Not Found",
      "status": 404,
      "traceId": "00-3133751a93eba87e2cb6fb795f3edb18-3cde9ee78c1c033f-00"
    }
    

    (The real body is one long line. It is shown here with line breaks, so it is easier to read. The traceId is added by ASP.NET and is explained later in this lesson. The fields come one by one in the section "Problem Details".)

    Three ideas stand behind the table:

    1. The status code says whether it worked (see HTTP and REST concepts). 2xx is success, 4xx means the client asked for something wrong, 5xx means our side broke. The status never lies. The success codes we use are few: 200 for a read, 201 for a create, 204 when there is nothing to send.
    2. The body of a failure is a standard shape, called Problem Details, defined in a public document (an IETF standard, RFC 9457). ASP.NET Core can write it for us.
    3. The unexpected is not caught in every action. The exception is allowed to escape, and one handler for the whole API turns it into the same standard shape.

    Not every team follows this, and you will meet projects that do not. It is the standard way to build an HTTP API, and the framework is built to make it easy, so this course follows it.

    The payoff is for the frontend developer of the team workflow, who gets a single if. If the status is 2xx, the body is the data. If not, read Problem Details. With the invented ways above, the same developer would need a different check for every endpoint. The rest of the lesson builds the failure half, since reading books already gave us the success half.


    Planned and unplanned errors

    There are two kinds of errors in an API, and they are not the same job. A quick way to tell them apart: did someone write a line of code that chose this answer? If yes, it is planned. If the answer came from an exception that nobody chose, it is unplanned.

    In plain words, the two kinds differ in what went wrong and who can fix it. A planned error is about the caller's request or the shop's rules, such as a missing book or a missing title. The caller can fix it, so we tell them exactly what is wrong. An unplanned error is a failure of the machinery under the app, such as a missing table or a bug. The caller cannot fix it, so we tell them almost nothing (the security section below says why). A later lesson looks at the shop's own rules, such as "not enough copies in stock".

    Planned: the client asked for something wrong

    Our code (or [ApiController]) saw the problem coming and picked the answer on purpose. The fault is the client's, and the status is 4xx.

    What the client did The answer Who chose it
    Asked for a book that does not exist: GET /books/99 404 Not Found Our code, with NotFound()
    Sent an id that is not a number: GET /books/abc 400 Bad Request [ApiController]
    Asked for an address that does not exist: GET /nothing 404 Not Found ASP.NET itself (we give it a body later in this lesson)
    Sent a new book with no title 400 Bad Request The validation rules
    Asked to sell more copies than the shelf holds 409 Conflict Our business rule, in the service
    Tried to open something it may not see 403 Forbidden The login rules

    In code, a planned error is a line we wrote:

    var book = await _bookService.GetByIdAsync(id);
    if (book == null)
    {
        return NotFound();      // we chose 404, on purpose
    }
    

    We wrote NotFound() and nothing else. The [ApiController] attribute did the rest: whenever an action returns a bare 4xx status, it fills in the Problem Details body shown earlier. It also stops a request whose input is invalid before our method runs, and answers with a Problem Details 400. That is a gift from [ApiController] for the errors it can see.

    Unplanned: our side broke

    Nobody chose this. Something went wrong that the code did not expect, an exception escaped, and the framework has to answer. The fault is ours, and the status is 500.

    What went wrong Why nobody planned it
    The database table is missing (the case we cause below) An operator changed the file by mistake
    The database server is down, or the file is locked The network or the machine failed
    The disk is full when a book is saved The machine ran out of space
    A bug: the code reads the first book of an empty list The developer did not think of an empty shop
    A bug: the code divides by the number of books, and there are none The same, in a report

    In code, an unplanned error is a line that looks fine:

    var books = await _bookService.GetAllAsync();
    var firstTitle = books[0].Title;          // the list is empty: an exception escapes
    var average = totalPrice / books.Count;   // zero books: another one
    

    Nothing in these lines says "answer with a 500". The exception climbs out of the method, and what happens next is the subject of the rest of the lesson.

    The first digit of a status code says whose fault it was: 4xx means "your request is wrong", 5xx means "our side broke". The client may fix a 4xx and try again. A 5xx it can only report.

    The frontend developer wants every one of them to look alike, as in the bottom row of the picture at the top. The planned errors already work, thanks to [ApiController] and NotFound(). The unplanned ones do not yet, and we will see how they fail.


    Problem Details

    Here is the 404 body from "The standard way", field by field.

    Problem Details is a standard way to describe an error in JSON. It is written down in a public document (RFC 9457; the older RFC 7807 was the same idea), so many APIs and many client libraries use it. Its media type is application/problem+json, which is why the Content-Type of those answers was not plain application/json.

    The fields are few:

    Field What it holds Example
    type A web address that names the kind of problem. ASP.NET fills in the HTTP standard's page for that status. https://tools.ietf.org/html/rfc9110#section-15.5.5
    title A short summary for a person. The same for every error of that kind. Not Found
    status The HTTP status code, repeated in the body. 404
    detail An optional sentence about this one case. No book has the id 99.
    traceId Not part of the standard. ASP.NET adds it. A number that identifies this one request. See "The trace id", below. 00-3133751a...

    The standard allows extra fields, and ASP.NET uses that for traceId. The 400 we saw when reading books added one more, errors, with a list of what was wrong. A later lesson uses it.

    What is type for?

    The type field is a link. The one ASP.NET puts there points into RFC 9110, the public document that defines HTTP. Section 15.5.5 of that document is the page for "404 Not Found", and the neighbouring sections describe 400, 405, 415, 500 and the rest. We checked a few of these sections to be sure.

    Why a link, when the status already says 404? Because you do not have to invent the vocabulary of errors. Over many years, many people built many APIs and met the ways a request goes wrong. HTTP collected the common ones, gave each a number and wrote down what it means. A developer who gets an answer they do not understand can follow type to the official explanation, without asking the author of the API.

    Three honest limits:

    • The catalogue is general. It knows "not found", not "this book is out of stock". Rules that belong to our bookshop are ours to describe.
    • Teams can use their own addresses. A bookshop could put https://bookshop.example/errors/out-of-stock in type, with a page that explains that one error. We do not do this in the course, but it is what the field is for.
    • Nobody has to open the link. The frontend works from the status and the title. The link is "more information", for a person who wants it. (The old tools.ietf.org address redirects to the RFC's page. It may not scroll to the section, so look for 15.5.5 on the page.)

    Want to see the catalogue for yourself? Open these three sections. Each opens in a new tab, so you do not lose your place here. They are short, and each shows something about how HTTP thinks:

    • 409 Conflict: "the request could not be completed due to a conflict with the current state" of the thing. Its own example is a PUT that clashes with a change someone else made first, which is the lost update: two people changing the same book.
    • 410 Gone: like 404, but it says "this was here, was removed on purpose, and will not come back". After a DELETE it would be a more exact answer than 404. The text also says that a server which does not know whether the removal is permanent should use 404, which is why we do.
    • 418 (Unused): the standard's own note about a joke status code from an April Fools' document, which was used so often as a joke that the number can no longer be given a real meaning.

    You do not need to read more than a paragraph of each. The point is that the catalogue is real, and a few minutes in it teaches you the words other developers will use about your API.

    Why one shape

    The point of one shape is the person at the other end. A page that calls the API reads status and title the same way for every failure. It never has to ask "is this error plain text, HTML, or JSON?".


    Cause a real 500

    The planned errors are easy to cause. An unplanned one needs a real failure, and this one has nothing to do with books or the shop's rules: it is the machinery under the app that breaks, a system failure. Let us break the database the way an operator might by mistake: the app starts, but the table is missing.

    Open src/BookShop.Api/appsettings.json and change the connection string to a file that does not exist yet. The whole file is:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "ConnectionStrings": {
        "BookShop": "Data Source=empty.db"
      }
    }
    

    (Your real bookshop.db is not touched.) SQLite creates empty.db the first time the app opens it, and it has no tables. Start the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio) and ask for the list:

    curl -i http://localhost:5101/books
    
    HTTP/1.1 500 Internal Server Error
    Content-Type: text/plain; charset=utf-8
    
    Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'no such table: Books'.
       at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
       at Microsoft.Data.Sqlite.SqliteCommand.PrepareAndEnumerateStatements()+MoveNext()
       ...
       at BookShop.Data.BookService.GetAllAsync() in .../src/BookShop.Data/BookService.cs:line 16
       at BooksController.GetAll() in .../src/BookShop.Api/Controllers/BooksController.cs:line 18
       ...
    

    (Many lines are left out, and the folder names are shortened. The real answer is about eighty lines long.)

    That wall of text is a stack trace, which we learned to read in the lesson on exceptions. The first line says what happened: no such table: Books. The lines about Microsoft.Data.Sqlite and Microsoft.EntityFrameworkCore are the libraries, which we skip. The first line with our own file name is BookService.GetAllAsync() in BookService.cs, where the query was made. It is plain text, not the standard shape the frontend expects. It also shows far too much, and the security section below explains why that matters. First, the fix.


    The fix, in two lines

    ASP.NET has a built-in exception handler. It is a piece of middleware: a step in the pipeline of creating the solution, code that sits in front of the controllers and sees every request go in and every answer come out. The exception handler does one job. It waits for an exception to come back up through the pipeline, and if one does, it writes an answer.

    An exception travels back up to the handler Exception handler Controller Service Database exception: no such table The handler catches it on the way back, and writes the 500. It must stand first in line, so that everything is behind it.

    Two lines of setup turn it on. Open src/BookShop.Api/Program.cs. The whole file is:

    using BookShop.Data;
    using Microsoft.EntityFrameworkCore;
    using Scalar.AspNetCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddControllers();
    builder.Services.AddOpenApi();
    builder.Services.AddProblemDetails();                                // new
    
    var connectionString = builder.Configuration.GetConnectionString("BookShop");
    builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
    builder.Services.AddScoped<IBookService, BookService>();
    
    var app = builder.Build();
    
    app.UseExceptionHandler();                                           // new
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        app.MapScalarApiReference();
    }
    
    app.UseHttpsRedirection();
    
    app.MapControllers();
    
    app.Run();
    
    • AddProblemDetails() registers the service that writes Problem Details bodies. It goes with the other registrations, before Build().
    • UseExceptionHandler() adds the middleware. Order matters: a Use... line adds a layer to the pipeline in the order written, and the handler can only catch what happens behind it. So it comes first, right after Build().

    Restart the app and ask again:

    curl -i http://localhost:5101/books
    
    HTTP/1.1 500 Internal Server Error
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
      "title": "An error occurred while processing your request.",
      "status": 500,
      "traceId": "00-b5c6c30209855ac391b528baf8ede7d6-9474f381cf2fb807-00"
    }
    

    (A few header lines are left out.) The same shape as the 404, and no stack trace, even in Development. (Where did the stack trace go? The next section answers that.) A 500 is a 5xx code, and type points at the HTTP standard's page for it. Notice what our controller did not need: a try/catch. The exception simply escapes, and the handler answers.


    Security: keep the details of a crash away from users

    The crash answer we saw first, the stack trace, is the worst thing to leave in place. It is useful to a developer. It is a gift to anyone who wants to attack the system. This is a security point that every API must get right.

    The rule is the one from the start of the lesson. We tell a caller exactly what is wrong with a planned error, because they can fix it. A system failure is different: they cannot fix it, and the details only help an attacker.

    Why it matters

    • It helps an attacker. The answer names the database, the table, the library, the folder layout and the exact lines of our code. Each is a hint about where to dig. A browser gets an even longer page, with the request's headers on it too.
    • It means nothing to the user. Nobody can act on ThrowExceptionForRC.
    • It looks careless. A customer who meets a wall of code, file paths and database errors reads it as unfinished work, and trusts the shop less. Every crash is a chance for the product to look broken, and this is the one place the team can choose how it looks.
    • It breaks the frontend. A page that expects Problem Details gets text, or HTML, and cannot show a clean message.

    What the framework does by default

    The stack trace came out in the Development environment (the template starts the app as Development, as the books service noted). So is a live environment safe? Run it as Production to see. To see the default, first comment out the line app.UseExceptionHandler(); in Program.cs. When you start with dotnet run, a file called launchSettings.json forces Development, so tell dotnet to ignore it:

    ASPNETCORE_ENVIRONMENT=Production dotnet run --no-launch-profile --project src/BookShop.Api
    

    (That line is for bash. In PowerShell, set the variable first: $env:ASPNETCORE_ENVIRONMENT="Production", then run the same dotnet run without the prefix.)

    HTTP/1.1 500 Internal Server Error
    Content-Length: 0
    

    Nothing leaks, but nothing is said either: a 500 with an empty body. The user sees nothing, and the frontend has nothing to read. (Put the line back before you go on.) With the handler, we get a third result, between the two: a short, clean message for the client, and the full detail for us.

    One failure, three results Development, no handler Production, no handler With a handler client sees the stack trace client sees an empty body client sees clean Problem Details the log holds the error the log holds the error the log holds the error and the same trace id The same failure in all three. Only the handler gives both sides what they need.

    Where the details go instead

    Not lost. The handler logs them. The terminal where the app runs shows the whole thing:

    fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
          An unhandled exception has occurred while executing the request.
          Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'no such table: Books'.
             at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
             ...
    

    This is the split a professional API makes. The client gets a short message. The developers get the full detail, in a place the client cannot see. In Development you still read the trace, in your terminal, so you lose nothing by hiding it from the response.

    Live, that "place" is a log file or a log service that the team reads. This course stops at the terminal.

    The trace id

    One piece of the answer is still useful to the user: the traceId. A user cannot read a stack trace, but they can copy a number into an email: "I got an error, id 00-b5c6...". The team searches the logs for that number and finds the exact request. One plain line in the body saves a long conversation.


    A handler of our own

    The two lines are enough for many projects. So why write a class?

    • The wording is ours. The default title, "An error occurred while processing your request.", is generic. We can say something better.
    • The log line is ours. We can write which request failed and its trace id in the log line itself, so the number a user quotes can be found with one search.
    • It has a place to grow. Later, some exceptions may need another status (a conflict, a service that is down). That decision belongs in one class that we can test.
    • It can be tested. The built-in middleware is hard to test. A class with one method is easy.

    None of that is needed on day one, and this is a judgement call. We build it now because a handler is something every API ends up with, and writing it takes only a few lines.

    ASP.NET gives an interface for it, IExceptionHandler (an interface, as in dependency injection basics: a list of what a class can do). It has one method. If the method returns true, the exception is handled. Create GlobalExceptionHandler.cs in src/BookShop.Api/. The whole file is:

    using System.Diagnostics;
    using Microsoft.AspNetCore.Diagnostics;
    using Microsoft.AspNetCore.Mvc;
    
    public class GlobalExceptionHandler : IExceptionHandler
    {
        private readonly ILogger<GlobalExceptionHandler> _logger;
    
        public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        {
            _logger = logger;
        }
    
        public async ValueTask<bool> TryHandleAsync(
            HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
        {
            var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
            _logger.LogError(exception, "Unhandled exception on {Method} {Path}, trace {TraceId}",
                httpContext.Request.Method, httpContext.Request.Path, traceId);
    
            var problem = new ProblemDetails
            {
                Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
                Title = "Something went wrong on our side.",
                Status = StatusCodes.Status500InternalServerError,
                Detail = "The error has been logged. Quote the trace id if you contact support."
            };
            problem.Extensions["traceId"] = traceId;
    
            httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
            await httpContext.Response.WriteAsJsonAsync(
                problem, options: null, contentType: "application/problem+json", cancellationToken);
            return true;
        }
    }
    
    • : IExceptionHandler is the promise: the class has TryHandleAsync. The middleware calls it with the request (httpContext) and the exception that escaped.
    • ILogger<GlobalExceptionHandler> comes in through the constructor, by DI. A logger writes lines to the log. ASP.NET registers it for us. The type in the angle brackets is just the name that appears on each line.
    • _logger.LogError(exception, "...") writes the line at the Error level, with the whole exception attached. This is where the stack trace goes now. The {Method}, {Path} and {TraceId} in the message are filled by the values after it.
    • Activity.Current?.Id is the same trace id that ASP.NET puts in its own answers. The ?. and ?? mean "use it if it exists, otherwise use TraceIdentifier".
    • ProblemDetails is a class that holds the fields of the table above. We fill what we want. Extensions is where an extra field such as traceId goes.
    • Not in the body: the exception's message, its type, and its stack trace. The Title and Detail are our own sentences. This is the safety rule of the lesson, in the shape of code: the exception goes to the log, and only our own sentence goes to the client.
    • WriteAsJsonAsync(...) writes the object as JSON, with the Problem Details media type.
    • return true says "handled, stop here". Returning false would let the next handler, or the default, try.

    Register it. Open src/BookShop.Api/Program.cs. The whole file is:

    using BookShop.Data;
    using Microsoft.EntityFrameworkCore;
    using Scalar.AspNetCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddControllers();
    builder.Services.AddOpenApi();
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>();      // new
    builder.Services.AddProblemDetails();
    
    var connectionString = builder.Configuration.GetConnectionString("BookShop");
    builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
    builder.Services.AddScoped<IBookService, BookService>();
    
    var app = builder.Build();
    
    app.UseExceptionHandler();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        app.MapScalarApiReference();
    }
    
    app.UseHttpsRedirection();
    
    app.MapControllers();
    
    app.Run();
    

    One new line. UseExceptionHandler() is unchanged: it finds the registered handler and asks it first. Restart and ask again:

    curl -i http://localhost:5101/books
    
    HTTP/1.1 500 Internal Server Error
    Content-Type: application/problem+json
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
      "title": "Something went wrong on our side.",
      "status": 500,
      "detail": "The error has been logged. Quote the trace id if you contact support.",
      "traceId": "00-34e9a49a3a1a374953fb1dc05f113d4d-042e12aa526cfc79-00"
    }
    

    And in the terminal where the app runs, the log line now carries the same number:

    fail: GlobalExceptionHandler[0]
          Unhandled exception on GET /books, trace 00-34e9a49a3a1a374953fb1dc05f113d4d-042e12aa526cfc79-00
          Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'no such table: Books'.
             at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32 rc, sqlite3 db)
             ...
    

    (The traceId differs on every request.) The user quotes 00-34e9..., and a search of the log finds this line, with the exception under it. When a handler returns true, the built-in middleware did not log the exception a second time in our run, so there is one line, not two.

    💡 We kept to one handler for every exception. There is no BookNotFoundException or similar class, on purpose: the service returns null and the controller returns NotFound(), which is simpler. Some teams use exceptions for planned errors too. That is a real style, and this course does not need it.


    Errors with no body

    One more gap. Ask for an address that does not exist, and send a wrong verb to a real one:

    curl -i http://localhost:5101/nothing
    
    HTTP/1.1 404 Not Found
    Content-Length: 0
    

    An empty 404. This one is not from our code or from [ApiController]: no controller matched the address, so ASP.NET answers on its own, and says nothing. The frontend would meet a failure with no shape. The same happens for a wrong verb, for example DELETE /books/1 today, which gets an empty 405. One more line fixes all of them. In Program.cs, after UseExceptionHandler():

    app.UseExceptionHandler();
    app.UseStatusCodePages();                                            // new
    

    (The rest of the file is as above.) UseStatusCodePages() looks at every answer that has an error status and no body. Because AddProblemDetails() is registered, it fills the body with Problem Details. After a restart:

    curl -i http://localhost:5101/nothing
    
    HTTP/1.1 404 Not Found
    Content-Type: application/problem+json
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
      "title": "Not Found",
      "status": 404,
      "traceId": "00-48e33ff8367e546406f8404cbb82773d-038864204c494a35-00"
    }
    

    Now every failure the API can give, planned or not, comes back as Problem Details.


    Put the connection string back

    The experiment is over. Open src/BookShop.Api/appsettings.json and restore the real name:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "ConnectionStrings": {
        "BookShop": "Data Source=bookshop.db"
      }
    }
    

    Delete the empty empty.db that SQLite made in src/BookShop.Api/. Start the app and check that curl http://localhost:5101/books shows your books again.


    Test the handler

    The handler is a class with one method, so it can be tested like the others. Three promises matter:

    1. It answers 500, in the Problem Details type.
    2. It does not leak: nothing from the exception reaches the body.
    3. It logs the exception as an error.

    For the third we need to see what was logged, so we use the idea from reading books once more: a hand-written fake. ILogger<T> is small enough to fake. Create FakeLogger.cs in tests/BookShop.Api.Tests/. The whole file is:

    using Microsoft.Extensions.Logging;
    
    namespace BookShop.Api.Tests;
    
    public class FakeLogger<T> : ILogger<T>
    {
        public List<LogLevel> Levels { get; } = new List<LogLevel>();
        public List<Exception?> Exceptions { get; } = new List<Exception?>();
    
        public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        {
            return null;
        }
    
        public bool IsEnabled(LogLevel logLevel)
        {
            return true;
        }
    
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
            Exception? exception, Func<TState, Exception?, string> formatter)
        {
            Levels.Add(logLevel);
            Exceptions.Add(exception);
        }
    }
    

    ILogger asks for three methods. Two do nothing useful here (BeginScope, IsEnabled). The one that matters is Log, which just writes the level and the exception into two lists that the test can read. (The <T> is a generic parameter, new here: it lets one fake class stand in for the logger of any class.)

    Now the tests. Create GlobalExceptionHandlerTests.cs in the same folder. The whole file is:

    using System.Text.Json;
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.Logging;
    
    namespace BookShop.Api.Tests;
    
    [TestClass]
    public class GlobalExceptionHandlerTests
    {
        private FakeLogger<GlobalExceptionHandler> _logger = null!;
        private GlobalExceptionHandler _handler = null!;
        private DefaultHttpContext _context = null!;
    
        [TestInitialize]
        public void Setup()
        {
            _logger = new FakeLogger<GlobalExceptionHandler>();
            _handler = new GlobalExceptionHandler(_logger);
            _context = new DefaultHttpContext();
            _context.Response.Body = new MemoryStream();
        }
    
        private async Task<string> ReadBodyAsync()
        {
            _context.Response.Body.Position = 0;
            return await new StreamReader(_context.Response.Body).ReadToEndAsync();
        }
    
        [TestMethod]
        public async Task TryHandleAsync_AnyException_Answers500AsProblemDetails()
        {
            var handled = await _handler.TryHandleAsync(
                _context, new InvalidOperationException("boom"), CancellationToken.None);
    
            Assert.IsTrue(handled);
            Assert.AreEqual(500, _context.Response.StatusCode);
            Assert.StartsWith("application/problem+json", _context.Response.ContentType);
            var body = JsonDocument.Parse(await ReadBodyAsync());
            Assert.AreEqual(500, body.RootElement.GetProperty("status").GetInt32());
        }
    
        [TestMethod]
        public async Task TryHandleAsync_ExceptionWithSecret_DoesNotLeakItToTheClient()
        {
            var exception = new InvalidOperationException("Cannot open Data Source=secret.db");
    
            await _handler.TryHandleAsync(_context, exception, CancellationToken.None);
    
            var body = await ReadBodyAsync();
            Assert.DoesNotContain("secret.db", body);
            Assert.DoesNotContain("InvalidOperationException", body);
        }
    
        [TestMethod]
        public async Task TryHandleAsync_AnyException_LogsItAsAnError()
        {
            var exception = new InvalidOperationException("boom");
    
            await _handler.TryHandleAsync(_context, exception, CancellationToken.None);
    
            Assert.HasCount(1, _logger.Levels);
            Assert.AreEqual(LogLevel.Error, _logger.Levels[0]);
            Assert.AreSame(exception, _logger.Exceptions[0]);
        }
    }
    
    • DefaultHttpContext is a ready-made request and response, built for tests. No web server starts. We swap its response body for a MemoryStream, an in-memory pad we can read back afterwards (ReadBodyAsync).
    • The first test checks the three visible things: the handler said "handled", the status is 500, and the content type and the status field in the body match.
    • The second test is the one that matters for safety. The exception carries a message with a made-up secret. If a later change ever puts exception.Message into the response, this test fails.
    • The third test checks the other half: the exception was written to the log, as an error, and it is the very same exception object (AreSame).

    Run all tests from the solution folder:

    dotnet test
    
    Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 117 ms - BookShop.Api.Tests.dll (net10.0)
    Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
    

    Ten tests now: six for the API project (three controller tests, three for the handler), four for the data project. The order of the two lines and the durations may differ.

    The hand check

    The loop from the team workflow has a third step, a spot check by hand. We did it with curl above, with the connection string broken. You can do it in the test page too: break the connection string again, open /scalar, and send GET /books. The response panel should show the status 500 and the same JSON. Then put the connection string back.

    A handler that is never exercised is a handler that fails the first time it is needed, so it is worth the five minutes.


    A short rule book

    The point of the lesson, as a table to come back to. When you write an endpoint, you do not have to decide how to report a problem. Look it up:

    Situation Do this Never do this
    The thing asked for does not exist return NotFound(); (404) return null, or text with a 200
    The input is wrong Let [ApiController] and the validation rules answer 400 Check by hand and return a sentence
    Something unexpected breaks Let the exception escape to the global handler (500) Catch it in the action and return its message
    A success Return the data itself, with 200 (a read), 201 (a create) or 204 (nothing to send) Wrap it in { "success": true, ... }

    Where we ended

    Compare with your solution. One new class in the API, two new files in its tests, and a few lines in Program.cs:

    BookShop/
      BookShop.slnx
      Directory.Build.props
      Directory.Packages.props
      src/
        BookShop.Api/
          Program.cs                         AddExceptionHandler, AddProblemDetails, UseExceptionHandler, UseStatusCodePages
          appsettings.json                   unchanged (the real connection string is back)
          GlobalExceptionHandler.cs          logs the exception, answers 500 as Problem Details
          Controllers/
            BooksController.cs               unchanged
        BookShop.Data/
      tests/
        BookShop.Data.Tests/
        BookShop.Api.Tests/
          FakeBookService.cs
          BooksControllerTests.cs
          FakeLogger.cs                      a list instead of a log file
          GlobalExceptionHandlerTests.cs     three tests on the handler
    

    Recap

    • Many developers invent their own way to report errors: return null (a 204, which is a success), return a sentence or a wrapper with a 200, or catch every exception in every action. Each one hides the failure from tools that read status codes, and gives the frontend a new shape to learn.
    • The standard is: the status code says whether it worked, a failure's body is Problem Details, and one global handler answers the unexpected.
    • A planned error is one our code chooses (404, 400). An unplanned error is an exception that nobody caught, and the framework must answer it with a 500.
    • Problem Details is the standard JSON shape for an error: type, title, status, and optionally detail, plus extras such as traceId. Its media type is application/problem+json. [ApiController] gives it to the errors it can see.
    • Without a handler, Development sends the stack trace to the client, and Production sends an empty body. Both are wrong.
    • AddProblemDetails() and UseExceptionHandler() catch the exception on its way back up the pipeline and answer with Problem Details. The handler goes first in the pipeline.
    • The client gets a short message and the traceId. The log gets the whole exception. The trace id lets a user's report be matched to the log line.
    • IExceptionHandler is the place for our own wording and logging. It returns true when it has handled the exception.
    • UseStatusCodePages() gives the answers that have no body, such as an unknown address, the same Problem Details shape.
    • The handler is tested with a DefaultHttpContext and a hand-written FakeLogger.

    Questions to think about

    These have no answers here, on purpose. Try them from memory first.

    1. Why must a user never see a stack trace?
    2. Where does the detail of an unexpected error go instead, and how does a user's report find it again?
    3. Why is it a problem when each endpoint reports its errors in its own way, such as a sentence with a 200?
    4. What is the difference between a planned and an unplanned error, and who is at fault in each?
    5. A client sends a book that is missing its title. Which error would you expect, and what should the answer list? (The next lesson answers this.)

    Next lesson: create a book. The first endpoint that receives data, with validation, a 201 answer, and a first look at what can go wrong when a client sends more than we asked for.

    ← Exceptions and Stack TracesCreate a Book: POST →