# ADR: Error handling, one shape everywhere

Status: accepted, 2026-09-02, shipped as 1.0.0.29. Steve's ask, in the same
message as the telemetry: "hook up error handling and log every API call
and error, and every React error." The README also promised this at the
start of the build and it was still open.

## In plain words

Every error the site returns, from any endpoint, has the same shape (the RFC 9457 standard) with a plain message and a trace id, and a crash in one part of the page no longer blanks the whole screen. Every error, from the server or the browser, shows up on the Admin tab.

What that is worth: a developer handles errors one way everywhere and can find any one from its trace id, and the organization hears about a problem from its own dashboard before a customer reports it.

## Context

Three gaps, all of them honest ones the README already listed:

1. **An unhandled exception was a shapeless 500.** The error middleware
   recorded it for the Admin tab and rethrew, and the framework answered
   with an empty body. A caller learned nothing, and the response looked
   the same as a proxy failure.
2. **Two different 400 bodies.** A rejected query answered `{ "error":
   "..." }` and a rejected bid answered `{ "reason": "..." }`. The browser
   had to know which endpoint it had called to read the message, and a new
   endpoint had a coin flip to make.
3. **A render crash was a white page.** React unmounts the whole tree when
   a render throws. There was no boundary, so a bug in one component took
   the site with it, and nothing about it ever reached the Admin tab.

## Decision

**One failure shape: RFC 9457 ProblemDetails.** `AddProblemDetails` plus
`UseExceptionHandler` turn every unhandled exception into
`application/problem+json` with a status, a title and a trace identifier.
Every deliberate 400 uses `Results.Problem(...)` with the human-readable
message in `detail`, so a query rejection and a bid rejection read the
same way:

```json
{ "type": "...", "title": "The bid was rejected", "status": 400,
  "detail": "Your bid must be at least $23,300.", "traceId": "00-a1b2..." }
```

The 404 for an unknown vehicle stays an empty 404: there is nothing to
say that the status code does not. (That sentence stopped being true
without a test noticing, and the addendum at the end of this record says when and what
replaced it.)

**The browser reads `detail` first.** `src/lib/data.ts` prefers `detail`,
falls back to the old `reason` and `error` keys so nothing breaks
mid-deploy, and then to a generic sentence. One helper, used by every
call.

**Every request is logged, as structured JSON.** `AddHttpLogging` records
the method, path, status and duration for every API call; the console
formatter writes JSON so a log line is machine-readable wherever it lands
(ADR: Telemetry, when it ships, sends the same events to Application
Insights). Static files and the SPA fallback are excluded, or the log is
mostly bundle chunks.

**A React error boundary at the root.** `ErrorBoundary` wraps `<App />`
in `app/mount.tsx`. A render crash shows what happened plus two ways out
(reload, or back to the inventory with the query string dropped) instead
of a blank page. Errors that never reach a boundary, thrown in an event
handler or an unhandled promise rejection, are caught by
`window.onerror` and `window.onunhandledrejection`.

**Browser errors land where server errors already do.** All three paths
POST to `/api/errors/client`, which records into the same ring buffer the
Admin tab reads, tagged with the page the visitor was on. The Admin tab's
Recent errors card now shows both sides of the app.

## In the code

The handler and the logging, in `api/TheYard.Api/Program.cs`:

```csharp
// Every failure answers RFC 9457 ProblemDetails (ADR-023): one shape for a
// rejected query, a rejected bid and an unhandled exception alike, so a caller
// reads one field, `detail`, for the message. The trace identifier ties the
// response to the request's log line.
builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = context =>
        context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier);
// What goes in that shape when nothing planned the failure: which exceptions
// are the caller's fault and may say so, what a 500 is allowed to reveal, and
// the log line that makes the returned trace id worth having (ADR-030).
builder.Services.AddExceptionHandler<ProblemHandler>();
// A body that will not parse answers a bare 400 with nothing in it by default,
// because the framework would rather not spend an exception on a bad request.
// That left one kind of failure on this API with no sentence in it. One shape
// for every failure is worth an exception on a request that was already wrong
// (ADR-030).
builder.Services.Configure<RouteHandlerOptions>(options => options.ThrowOnBadRequest = true);

// No HTTP logging middleware. AddHttpLogging and UseHttpLogging stood here from
// the first commit to the pipeline lane, and never wrote a line: the logging
// configuration has always held Microsoft.AspNetCore at Warning, and the
// middleware logs at Information, so it checked, found itself filtered, and
// passed every request on. Every request is already recorded twice, by the
// timing ring in RequestPipeline.cs and by OpenTelemetry's request telemetry to
// Application Insights, whose daily cap a line per request would spend by noon
// (ADR: The order of the request pipeline).
builder.Logging.AddJsonConsole(options => options.IncludeScopes = false);
```
*Live from [`api/TheYard.Api/Composition/ApiRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/ApiRegistration.cs#L63-L89), region `problem-details`, read from this build at 1d6b50b.*

The endpoint browser errors report to:

```csharp
// Browser errors land where server errors already do (ADR-023): a render
// crash caught by the boundary, or an unhandled rejection, POSTs here and
// shows up on the Admin tab's Recent errors card tagged with the page the
// visitor was on. Status 0 marks the entry as coming from the browser.
app.MapPost("/api/errors/client", BrowserReport)
    .WithName("ReportBrowserError")
    .WithTags("Errors")
    .WithSummary("Report an error the browser caught")
    .WithDescription("Anonymous by design, so a crash in the page reaches the same list a crash in the server does. "
        + "The message and the stack are bounded and any address in them is masked before they are kept.")
    .ProducesProblem(StatusCodes.Status400BadRequest);
```
*Live from [`api/TheYard.Api/Endpoints/ErrorEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/ErrorEndpoints.cs#L25-L35), region `client-errors`, read from this build at 1d6b50b.*

A deliberate 400, in the same shape:

```csharp
// All filters, sorting, and paging are optional GET parameters, applied
// server-side. The default page is the top 100 by auction time (live and
// ending soonest first). Responses are an envelope: { total, vehicles },
// with each vehicle carrying the server-derived auction facts.
// e.g. /api/vehicles?make=Ford&status=live&sort=price-asc&limit=100&offset=100
private static Results<Ok<VehiclePage>, ProblemHttpResult> List(
    CurrentBackend current,
    [AsParameters] VehicleQueryParams query)
{
    if (!query.TryBuildFilter(out var filter, out var clock, out var sort, out var error))
    {
        // One failure shape for the whole API: the message a person can act on goes in
        // `detail`, never in a key only this endpoint uses.
        return TypedResults.Problem(detail: error, statusCode: 400, title: "The query could not be read");
    }
    // The auction hands back each vehicle at its standing price with its sold flag, so a
    // listing never composes the bids, the room or the sale itself.
    var result = current.Auction.Search(filter, clock, sort, query.EffectiveLimit, query.EffectiveOffset);
    return TypedResults.Ok(new VehiclePage(result.Total, [.. result.Vehicles.Select(v => VehicleWire.ToWire(v, clock))]));
}
```
*Live from [`api/TheYard.Api/Endpoints/VehicleEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/VehicleEndpoints.cs#L43-L62), region `inventory-endpoint`, read from this build at 1d6b50b.*

The boundary, and the reporter every path uses:

```tsx
/**
 * The last line of defense for a render crash (ADR-023). React unmounts the
 * whole tree when a render throws, so without a boundary the visitor gets a
 * blank white page and no way back. This catches the throw, shows what
 * happened, and offers the two moves that actually help: reload, or go back
 * to the inventory with the failing query string dropped.
 *
 * It must be a class: `componentDidCatch` and `getDerivedStateFromError` have
 * no hook equivalent, and this is the one place in the app that needs them.
 * Errors thrown in event handlers and promises never reach a boundary, which
 * is why app/reportUncaughtErrors.ts also reports those (ADR: Error handling).
 */
export class ErrorBoundary extends Component<
  { children: ReactNode },
  { error: Error | null; reloading: boolean }
> {
  state: { error: Error | null; reloading: boolean } = { error: null, reloading: false };

  /**
   * Runs during the failed render: the only place state may be set from an error.
   * A stale chunk is drawn as the new version loading from this first commit, so
   * the error card never flashes before the reload; componentDidCatch takes that
   * back when it does not reload.
   */
  static getDerivedStateFromError(error: Error) {
    return { error, reloading: isStaleChunk(error) };
  }

  /**
   * Runs after: side effects belong here, never in the method above. A chunk
   * the page names that a deploy has since replaced is not a crash: the page
   * loads again onto the new version, once (src/lib/staleChunk.ts), and says
   * so for the moment the reload takes. Anything else is reported and shown.
   */
  componentDidCatch(error: Error, info: ErrorInfo) {
    const reloaded =
      isStaleChunk(error) &&
      reloadOnce(tabStorage(), () => window.location.reload(), { online: navigator.onLine });
    if (reloaded) return;
    if (this.state.reloading) this.setState({ reloading: false });
    reportClientError(error, info.componentStack ?? undefined);
  }

  render() {
    if (!this.state.error) {
      return this.props.children;
    }
    if (this.state.reloading) {
      return (
        <div
          className={`${styles.wrap} op-glass op-solid`}
          role="status"
          data-testid="stale-reload"
        >
          <p className={styles.body}>The site was just updated. Loading the new version.</p>
        </div>
      );
    }
    return (
      <div className={`${styles.wrap} op-glass op-solid`} role="alert" data-testid="render-error">
        <h1 className={styles.title}>Something went wrong on this page.</h1>
        <p className={styles.body}>
          The inventory is still running. Reload, or go back to the full list and try again.
        </p>
        <p className={styles.detail}>{this.state.error.message}</p>
        <div className={styles.actions}>
          <button type="button" className={styles.primary} onClick={() => window.location.reload()}>
            Reload the page
          </button>
          <button
            type="button"
            className={styles.secondary}
            onClick={() => {
              window.location.href = window.location.pathname;
            }}
          >
            Back to the inventory
          </button>
        </div>
      </div>
    );
  }
}
```
*Live from [`src/components/shared/ErrorBoundary/ErrorBoundary.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/shared/ErrorBoundary/ErrorBoundary.tsx#L6-L88), region `boundary`, read from this build at 1d6b50b.*

```tsx
/**
 * Reports a browser-side error to the API, which records it in the same ring
 * buffer the Admin tab reads (ADR: Observability). Fire and forget on purpose:
 * a failed report must never replace the error the visitor is already seeing.
 */
export function reportClientError(error: unknown, componentStack?: string): void {
  const message = error instanceof Error ? error.message : String(error);
  const stack = error instanceof Error ? error.stack : undefined;
  try {
    void fetch('/api/errors/client', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      // keepalive lets the report survive a navigation away from the page.
      keepalive: true,
      body: JSON.stringify({
        message,
        stack: (componentStack ?? stack ?? '').slice(0, 2000),
        path: window.location.pathname + window.location.search,
      }),
    }).catch(() => {});
  } catch {
    // A browser that refuses the request tells us nothing useful; stay quiet.
  }
}
```
*Live from [`src/components/shared/ErrorBoundary/ErrorBoundary.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/shared/ErrorBoundary/ErrorBoundary.tsx#L92-L115), region `report`, read from this build at 1d6b50b.*

The two window-level handlers, in `src/app/reportUncaughtErrors.ts`:

```ts
/**
 * Listens on the window for errors and rejected promises and sends each one to the API, so the
 * Admin tab shows the browser's failures beside the server's (ADR: Error handling).
 */
export function reportUncaughtErrors(): void {
  // A throw inside an event handler, a timer or a script that is not React rendering.
  window.addEventListener('error', (event) => reportClientError(event.error ?? event.message));
  // A promise that failed with nothing waiting on it.
  window.addEventListener('unhandledrejection', (event) => reportClientError(event.reason));
}
```
*Live from [`src/app/reportUncaughtErrors.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/reportUncaughtErrors.ts#L9-L18), region `window-handlers`, read from this build at 1d6b50b.*

How the browser reads a failure:

```ts
/**
 * Reads the message out of a failed response. The API answers RFC 9457
 * ProblemDetails, so `detail` is the field to trust (ADR: Error handling);
 * `reason` and `error` are the two shapes it used to answer with, kept here
 * so a browser holding an old bundle still shows a real message during a roll.
 */
async function problemDetail(response: Response, fallback: string): Promise<string> {
  // A failure can arrive without a JSON body at all: an edge or proxy error
  // page, or an empty 502. Anything unreadable falls back to the sentence the
  // caller supplied rather than throwing a second error over the first.
  let body: { detail?: string; reason?: string; error?: string } | null = null;
  try {
    body = await response.json();
  } catch {
    body = null;
  }
  return body?.detail ?? body?.reason ?? body?.error ?? fallback;
}
```
*Live from [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/data.ts#L53-L70), region `problem-detail`, read from this build at 1d6b50b.*

## Consequences

- A caller can read one field, `detail`, for the message on any failure
  from this API.
- `traceId` on every problem response ties a visitor's report to a log
  line, and to a request in Application Insights once telemetry ships.
- The ring buffer holds fifty entries, browser and server together, and
  is in memory: it is a demo's observability, not an audit log. That
  limitation is recorded in ADR: Observability and is the reason the
  telemetry record exists.
- The boundary catches render crashes only. Event handlers and promises
  are covered by the window handlers, which is why both exist.
- Reporting is best effort. `keepalive` lets a report survive the
  navigation away, and a failed report is swallowed rather than replacing
  the error the visitor already sees.

## Addendum, 2026-09-15: the empty 404 was not empty

`UseStatusCodePages` was added to the pipeline after this record was written,
and with the problem-details service registered it fills any bare status code
with a ProblemDetails body on the way out. So the "empty 404" above had been
answering `application/problem+json` with a generic title for some time, and
nothing said so. When the API started describing itself (ADR: The API
describes itself), every declared response had to match the wire, and a
declaration of "empty" would have been the one lie in the document. The
endpoints that answer 404 and 401 now say so themselves, with a title and a
one-sentence detail in the shape every other failure here uses. The status
codes did not change; the body names what was not found instead of the
middleware naming nothing.

## Addendum, 2026-09-19: the frames, and still not the message

Steve, on a 500 he found on his own Admin tab: recent errors should be a table, and they should show
the stack trace with line numbers. Both, and the second one needs saying carefully, because this
record's own rule is that the list at `/api/errors` carries an exception's **type** and never its
message: a message is where a framework writes a filesystem path, a connection detail or the value
that broke a constraint, and the list is public.

The frames are a different thing from the message. A frame is a method, the file it is in and the
line it is on, which is source this repository already publishes in full, and it is the whole
distance between "something threw on `/api/admin/machines`" and "line 160 of Machines.cs reads a
decimal into a double". So the ring carries up to twelve frames per entry, the endpoint serves them,
the card shows them behind a disclosure in a table with the time, the status and the path, and the
message stays out. A test throws on the self-test endpoint and asserts both halves: the frames are
there, and the sentence the self-test throws with is not.

## Where it sits

The server half sits in the host Api: the problem-details registration in Composition/ApiRegistration.cs, the browser-report endpoint in Endpoints/ErrorEndpoints.cs and the error ring in Observability.cs. The browser half is front end: ErrorBoundary is a shared component, the `detail` reader is in src/lib/data.ts, and no inner ring changed, which suits a decision about how HTTP failures look. Single responsibility, a class with one reason to change, shows in the split: one helper in src/lib reads every failure body, and the boundary handles render crashes while the window handlers take everything else. The cost is a fifty-entry ring that empties on every roll, and a product with an audit requirement would send these errors to a durable store first.

## Files

- [`api/TheYard.Api/Composition/ApiRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/ApiRegistration.cs): the handler and the logging.
- [`api/TheYard.Api/Endpoints/ErrorEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/ErrorEndpoints.cs): the client-error endpoint.
- [`api/TheYard.Api/Endpoints/VehicleEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/VehicleEndpoints.cs) and [`api/TheYard.Api/Endpoints/AccountEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AccountEndpoints.cs): the 400s.
- [`api/TheYard.Api/Observability.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Observability.cs): the ring buffer both sides record into.
- [`src/components/shared/ErrorBoundary/ErrorBoundary.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/ErrorBoundary/ErrorBoundary.tsx) and [`ErrorBoundary.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/ErrorBoundary/ErrorBoundary.module.css): the boundary and the reporter.
- [`src/app/mount.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/app/mount.tsx): the boundary around the app.
- [`src/app/reportUncaughtErrors.ts`](https://github.com/SteveStout/TheYard/blob/main/src/app/reportUncaughtErrors.ts): the two window handlers, started from `src/main.tsx`.
- [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/data.ts): reading `detail`.
- [`api/TheYard.Tests/ProblemDetailsTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ProblemDetailsTests.cs): every 400 carries the same shape, and a browser report reaches the errors list.
- [`tests/e2e/admin.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/admin.spec.ts): a reported browser error appears on the Admin tab.
- [`docs/decisions/ADR-010-observability.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-010-observability.md): the Admin tab this feeds.

## Addendum, 2026-09-25 (1.0.3.27): a page left open across a deploy

Steve, on his iPhone, on the Admin tab after 1.0.3.26 rolled: every card he opened showed this boundary with "Importing a module script failed". His page was 1.0.3.25's. It asks for each card's chunk by the hashed name that build gave it, and the roll had replaced those files with 1.0.3.26's. Nothing was broken on the server, and a reload fixed it; the boundary was the wrong answer to a stale page. From 1.0.3.27 an error that is a chunk the server no longer has (each engine words it differently; `src/lib/staleChunk.ts` knows the three, and Vite's own preload error) loads the page again onto the new version, once, and says "The site was just updated" for the moment that takes, from the boundary's first frame, so the error card never flashes first. A page that is offline is not reloaded, since the same words come from a chunk that had no network and a reload would land on the browser's offline page. A second such failure inside a minute is not a stale page, so it is reported and shown, never looped. `admin.spec` stands in for a deploy by refusing the Errors card's chunk: refused once, the page reloads and the card opens; refused every time, the page reloads once and then shows the error.

## Addendum, 2026-10-07 (1.0.3.88): the boundary's place

The boundary still wraps `<App />` at the root, now in `src/app/TheYard.tsx`, the one tree that both the browser and the build draw (ADR: The landing page rendered at build time, server rendering as the goal); `app/mount.tsx` puts that tree on the page.

## Addendum, 2026-10-07 (1.0.3.92): the request log that never wrote

"Every request is logged, as structured JSON" above was not true on any day of this project. `AddHttpLogging` and `UseHttpLogging` were wired from the first commit, but the logging configuration has always held `Microsoft.AspNetCore` at Warning and the HTTP logger writes at Information, so it found itself filtered on every request and wrote nothing. Both are removed rather than switched on: every request is recorded already, by the timing ring on the Admin tab and by the request telemetry sent to Application Insights, and a line per request through the logger would spend that service's daily cap, which is already reached most afternoons. The error record moved up beside the exception handler in the same version, so a failure while reading the session now reaches the Admin tab (ADR: The order of the request pipeline).
