# ADR: The exception handler, and the endpoint that proves it

Status: accepted, 2026-09-03, shipped as 1.0.0.40. This extends
ADR: Error handling rather than replacing it: that record decided the shape,
this one decides what goes in it when nobody wrote the failure.

## In plain words

This page decides what the API says when code fails in a way nobody planned for (an unhandled exception). The caller gets one fixed sentence and a trace id, while the full details go to one log line with the same id. A deliberate failure endpoint proves the whole path works in production.

What that is worth: a developer can find any crash from the id a reader quotes, and the organization's error messages never draw a map of the inside of the process for whoever asks.

## Context

ADR: Error handling shipped `AddProblemDetails`, `UseExceptionHandler()` and a
`traceId` on every response, and the suite proves the shape on a rejected
query, a rejected bid, a rejected browser report and a 404. Every one of those
is a failure an endpoint returns on purpose.

The failure nobody wrote code for was never tested, and there is a reason it
was easy to miss: the built-in handler does produce a correctly shaped 500, so
reading the code gives no sign anything is missing. Running it does. Three
things were wrong.

**The developer exception page was answering, not the handler.** `WebApplication`
inserts it automatically in Development, in front of everything the app
registers. So the response a developer sees when something throws, and the
response a caller gets in production, were never the same response, and only
one of them had ever been looked at.

**The bare 500 says nothing at all.** No title, no detail. A caller gets a
status code and a trace id and no sentence, which is worse than it sounds: the
front end renders `detail` for every other failure, so a crash was the one case
that produced an empty message box.

**Nothing decided what a 500 may reveal.** Nothing leaked, but nothing was
stopping it either. The next person to add `Detail = ex.Message` to make
debugging easier would have been making a security decision without knowing it.

## Decision

**A custom `IExceptionHandler`, because three questions need an application's
answer and the framework cannot have one.**

*Which failures are the caller's fault.* `BadHttpRequestException` is the
framework saying the request was malformed, which is a different claim from
"this server broke". It carries its own status code and its message describes
the caller's own input, so it is answered at its status, with its message, and
logged as a warning rather than an error.

Minimal APIs do not raise that exception by default. A body that will not parse
answers a bare 400 with nothing in it, on the reasoning that a bad request
should not cost an exception. That left exactly one kind of failure on this API
without a sentence in it, so `RouteHandlerOptions.ThrowOnBadRequest` is on and
binding failures join everything else. The test that proves this is the test
that found it: it was written to assert one shape for every 400, and it failed
on the first run against the API it was describing.

*What a server failure may reveal.* One fixed sentence, `ProblemHandler.ServerDetail`,
and the trace id. The exception message, the type and the stack stay inside.
An error message that varies with the exception is a map of the inside of the
process, drawn for whoever asks.

*What makes the trace id worth returning.* One structured log line, written
before the response, carrying the exception, the method, the path, the status
and the same trace id. The response can afford to say nothing precisely because
everything it withholds is in that line.

There is a fourth case worth naming: a caller who hangs up mid-response
produces an `OperationCanceledException` on an aborted request. There is no
socket left to answer on, so nothing is written, and it does not belong in the
Admin tab's error list as a server failure.

The first version of this logged it at Debug and returned without touching the
status, which leaves a 200 on a request that was never answered. That is wrong
in the one situation it matters: the opening shape of a real outage is a slow
server, clients timing out, and every abandoned request landing here. A version
that logs at Debug and reports 200 makes exactly that invisible in the request
log and in telemetry at once. It logs at Information now and sets 499, which is
nginx's convention for a client that left and is understood widely enough to
read as what it is. That correction came from the staff review of the same
night's diff, not from a failure in production.

**A deliberate failure endpoint, `/api/admin/selftest/exception`.** Every other
endpoint here is written not to throw, which is why the exception path had
never once run in production: the middleware's catch, the ring buffer's
record, the handler's log line and the Application Insights `exceptions` table
the Admin tab reads were all assumed to work end to end and none of them had
been asked. This endpoint asks. It throws a sentence chosen to be searchable,
and the answer it produces is the answer any real bug would produce.

It is public, like every other endpoint on this site, and cheaper to serve than
the inventory query. `DELETE /api/bids`, which resets the auction for everyone,
has been public since the first deploy.

The exposure it does add is worth naming rather than leaving for someone to
find: the recent-errors buffer holds fifty entries, so fifty anonymous requests
to this endpoint would push every real error out of the Admin tab, and each one
also sends an exception to Application Insights, which is billed by ingestion.
On a demo with a public reset endpoint that is an acceptable trade. On anything
with users it would need a rate limit or an authenticated route, and the reason
it does not have one here is the deployment, not the design.

**The tests run against Production.** `ProductionApi` is a factory that sets
the environment, because asking what a caller sees means asking the environment
a caller reaches. This is the point the whole record turns on: a test against
the default environment would have passed while asserting the developer
exception page's HTML.

## In the code

The handler (`api/TheYard.Api/ProblemHandler.cs`):

```csharp
/// <summary>
/// Not an HTTP standard, and the standard has nothing for this. nginx uses
/// 499 for a client that closed the connection before an answer, enough
/// tooling understands it, and the alternative is leaving a 200 on a
/// request that was never answered.
/// </summary>
public const int ClientClosedRequest = 499;

/// <summary>
/// The sentence a caller gets for a server failure. It is deliberately the
/// same every time: an error message that varies with the exception is a
/// map of the inside of the process, drawn for whoever asks.
/// </summary>
public const string ServerDetail =
    "Something went wrong on the server. Quote the trace id below and the "
    + "request can be found in the logs.";

/// <summary>
/// Whether an exception means the caller left rather than the server
/// failing. It is a cancellation, and either the request's own token has
/// fired or the exception was raised by that token. Both are checked
/// because a wait cancelled by the request's token (Task.WaitAsync, for
/// one) can surface before the server notices the connection is gone. A
/// cancellation from any other token, such as a timeout of the server's
/// own, is a server failure and stays one.
/// </summary>
public static bool CallerLeft(HttpContext context, Exception exception) =>
    exception is OperationCanceledException cancelled
    && (context.RequestAborted.IsCancellationRequested
        || (context.RequestAborted.CanBeCanceled && cancelled.CancellationToken == context.RequestAborted));

/// <summary>
/// Answers a failure no endpoint handled: 499 and nothing written when the
/// caller left, the caller's own message for a malformed request, and the
/// fixed server sentence with a trace id for everything else.
/// </summary>
public async ValueTask<bool> TryHandleAsync(
    HttpContext context,
    Exception exception,
    CancellationToken cancellationToken)
{
    // The caller hung up mid-response. There is no socket left to answer
    // on, so nothing is written. It is still recorded, at Information and
    // with a status that says what happened: the first shape of a real
    // outage is clients timing out and aborting, so a request log that
    // showed these as 200, or dropped them, would hide that outage. 499 is
    // nginx's convention for it and reads in a log as "the caller left",
    // not "we answered". It is never an error, so the Admin tab's error
    // count does not go red for a visitor who closed a tab.
    if (CallerLeft(context, exception))
    {
        if (!context.Response.HasStarted)
        {
            context.Response.StatusCode = ClientClosedRequest;
        }
        log.LogInformation(
            "{Method} {Path} was abandoned by the caller; nothing was written",
            context.Request.Method,
            context.Request.Path);
        return true;
    }

    // BadHttpRequestException is the framework saying the request was
    // malformed, which is a different claim from "this server broke". Its
    // message describes the caller's own input, so passing it on is both
    // safe and the only useful thing to say.
    bool callersFault = exception is BadHttpRequestException;
    int status = callersFault
        ? ((BadHttpRequestException)exception).StatusCode
        : StatusCodes.Status500InternalServerError;

    // One line, before the response is written, carrying the trace id the
    // caller is about to be handed. This is the whole reason the response
    // can afford to say nothing: everything it withholds is here.
    log.Log(
        callersFault ? LogLevel.Warning : LogLevel.Error,
        exception,
        "{Exception} on {Method} {Path} answered {Status}, trace {TraceId}",
        exception.GetType().Name,
        context.Request.Method,
        context.Request.Path,
        status,
        context.TraceIdentifier);

    context.Response.StatusCode = status;
    // The same writer every deliberate Results.Problem call goes through,
    // so a crash and a rejected query are one shape on the wire. The
    // traceId extension is added by the customization in Program.cs, which
    // means it is on this response too without being repeated here.
    //
    // It can decline. A caller who accepts only text/plain gets no
    // ProblemDetails, TryWriteAsync returns false, and the middleware
    // rethrows, which is the right outcome and not the one the log line
    // above describes. So the log says what was actually sent.
    bool written = await problems.TryWriteAsync(new ProblemDetailsContext
    {
        HttpContext = context,
        Exception = exception,
        ProblemDetails = new ProblemDetails
        {
            Status = status,
            Title = callersFault
                ? "The request could not be read"
                : "The request could not be completed",
            Detail = callersFault ? exception.Message : ServerDetail,
        },
    });

    if (!written)
    {
        log.LogWarning(
            "Nothing was written for {Exception} on {Method} {Path}: no problem details writer accepted it, so the request will be reset",
            exception.GetType().Name,
            context.Request.Method,
            context.Request.Path);
    }

    return written;
}
```
*Live from [`api/TheYard.Api/ProblemHandler.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/ProblemHandler.cs#L20-L138), region `exception-handler`, read from this build at 1d6b50b.*

The five tests (`api/TheYard.Tests/ExceptionHandlerTests.cs`):

```csharp
[Fact]
public async Task An_unhandled_exception_answers_problem_details_carrying_a_trace_id()
{
    var response = await _client.GetAsync(SelfTest);

    Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    Assert.Equal(500, json.RootElement.GetProperty("status").GetInt32());
    Assert.Equal("The request could not be completed", json.RootElement.GetProperty("title").GetString());
    Assert.Equal(ProblemHandler.ServerDetail, json.RootElement.GetProperty("detail").GetString());
    // The trace id is the entire value of a response that says nothing
    // else, so an empty one would make the rest of this shape pointless.
    Assert.False(string.IsNullOrWhiteSpace(json.RootElement.GetProperty("traceId").GetString()));
}

[Fact]
public async Task The_exception_never_reaches_the_caller()
{
    string body = await (await _client.GetAsync(SelfTest)).Content.ReadAsStringAsync();

    // The three things a 500 leaks when nobody stops it: the message, the
    // type, and the stack. The endpoint throws with a distinctive sentence
    // precisely so this assertion can be about a real string.
    Assert.DoesNotContain("self-test", body, StringComparison.OrdinalIgnoreCase);
    Assert.DoesNotContain(nameof(InvalidOperationException), body, StringComparison.Ordinal);
    Assert.DoesNotContain("at TheYard", body, StringComparison.Ordinal);
}

[Fact]
public async Task A_thrown_request_reaches_the_list_the_admin_tab_reads()
{
    await _client.GetAsync(SelfTest);

    string errors = await _client.GetStringAsync("/api/errors");
    Assert.Contains(SelfTest, errors);
}

/// <summary>
/// The list carries the exception's frames, which is how a reader gets
/// from a 500 on the Admin tab to the line that threw, and still does not
/// carry its message, which is where a framework writes a connection
/// detail (ADR: Error handling, the addendum on frames).
/// </summary>
[Fact]
public async Task The_list_carries_the_frames_and_still_refuses_the_message()
{
    await _client.GetAsync(SelfTest);

    var entries = await _client.GetFromJsonAsync<JsonElement>("/api/errors");
    var thrown = entries.EnumerateArray()
        .First(entry => entry.GetProperty("path").GetString() == SelfTest
            && entry.GetProperty("status").GetInt32() == 500);
    var frames = thrown.GetProperty("frames").EnumerateArray().Select(frame => frame.GetString() ?? "").ToList();

    Assert.NotEmpty(frames);
    Assert.Contains(frames, frame => frame.Contains("TheYard", StringComparison.Ordinal));
    Assert.True(frames.Count <= StackFrames.Most, $"{frames.Count} frames is more than a reader gets through");
    // The sentence the self-test throws with is still nowhere in the list.
    Assert.DoesNotContain("self-test", string.Join(" ", frames), StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task A_malformed_body_is_the_callers_fault_and_says_which_part()
{
    var response = await _client.PostAsync(
        "/api/errors/client",
        new StringContent("{ this is not json", Encoding.UTF8, "application/json"));

    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    Assert.Equal(400, json.RootElement.GetProperty("status").GetInt32());
    Assert.Equal("The request could not be read", json.RootElement.GetProperty("title").GetString());
    // A caller who sent bad JSON is entitled to know that, which is the
    // one case where the exception's own message is the right answer.
    Assert.NotEqual(ProblemHandler.ServerDetail, json.RootElement.GetProperty("detail").GetString());
}

[Fact]
public async Task The_shape_of_a_crash_and_the_shape_of_a_rejected_query_are_the_same()
{
    using var crash = JsonDocument.Parse(
        await (await _client.GetAsync(SelfTest)).Content.ReadAsStringAsync());
    using var rejected = JsonDocument.Parse(
        await (await _client.GetAsync("/api/vehicles?sort=alphabetical")).Content.ReadAsStringAsync());

    // One caller, one parser: whatever field it reads for a rejected query
    // has to be there when the server falls over, or the client needs two.
    var crashFields = crash.RootElement.EnumerateObject().Select(p => p.Name).Order().ToArray();
    var rejectedFields = rejected.RootElement.EnumerateObject().Select(p => p.Name).Order().ToArray();
    // Two empty sets are equal, so without this the test would pass on two
    // empty bodies, which is the failure it exists to catch (the staff
    // review, 2026-09-03).
    Assert.NotEmpty(rejectedFields);
    Assert.Equal(rejectedFields, crashFields);
}
```
*Live from [`api/TheYard.Tests/ExceptionHandlerTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/ExceptionHandlerTests.cs#L41-L137), region `exception-tests`, read from this build at 1d6b50b.*

## Consequences

- A crash now renders a sentence in the browser instead of an empty message
  box, and the sentence tells the reader what to quote.
- The shape of a crash and the shape of a rejected query are asserted to have
  the same fields, so a client needs one parser and not two. That test is what
  will fail if someone later adds a field to one path only.
- The exception path can be exercised against the live container, which is how
  the Admin tab's Application Insights card gets checked against a real
  exception rather than an absence of them.
- `/api/admin/selftest/exception` will appear in the Admin tab's recent-errors
  list every time it is used, which is correct: it is a 500 and the list is the
  list of 500s.
- One thing is deliberately not done: known domain exceptions are not mapped to
  statuses, because this application does not throw any. Every rule that can
  reject something returns a result instead. If that changes, the mapping goes
  here.

## Where it sits

ProblemHandler is an IExceptionHandler in the host Api, registered in Composition/ApiRegistration.cs, and it is the one place that decides what an unplanned failure may say. Open/closed applies directly: the framework's exception pipeline stays untouched while the application adds its answer by implementing the interface, keeping the fixed sentence beside the log line in that single class. The cost is a public self-test endpoint that fifty anonymous calls could use to flush the error ring and add Application Insights ingestion. A site with real users would put that endpoint behind authentication or a rate limit, and would map domain exceptions here if the rules ever started throwing them.

## Files

- [`api/TheYard.Api/ProblemHandler.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/ProblemHandler.cs): the handler.
- [`api/TheYard.Api/Composition/ApiRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/ApiRegistration.cs): the registration and `ThrowOnBadRequest`.
- [`api/TheYard.Api/Endpoints/ErrorEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/ErrorEndpoints.cs): the self-test endpoint.
- [`api/TheYard.Tests/ExceptionHandlerTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ExceptionHandlerTests.cs): the five tests, and the Production factory.
- [`api/TheYard.Tests/ProblemDetailsTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ProblemDetailsTests.cs): the deliberate failures, unchanged.
- [`docs/decisions/ADR-023-error-handling.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-023-error-handling.md): the shape this fills in.
