Global exception handling and Problem Details (task 58) #18
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/problem-details"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Registers
UseExceptionHandlerimmediately insideUseHttpLogging, which fixes ADR-0015'sStatusCode: 200defect. Commenting the handler out fails 10 of 15 API tests, one with exactly the old symptom:The Development decision
The handler runs in every environment, unguarded, with an identical body everywhere and never any exception detail.
The customary
if (!app.Environment.IsDevelopment())guard would have fixed the logged status in deployed environments and left the200locally — where these logs are read most and doubted least — because the developer exception page sits outside all user middleware and cannot be brought inside. That is precisely why ADR-0015 handed this decision here.The cost is losing that page. What it showed is in the console beside the request, at
Error, with the stack trace, under the trace id the response body quotes.Also rejected: including the exception message in Development only. It makes the failure path the one part of the API never exercised in the shape it ships in, and puts a conditional leak one misconfiguration from production.
Design
ApiExceptionHandleranswers its own exception types and declines everything else, so there is no branch rendering an arbitrary exception into a response that could later be widened into one.Diagnostics suppressed for expected failures — without it a 404 was logged at
Errorwith a stack trace. A test pins that.Nothing added to
PlaceMark.Contracts. The wire shape is the framework'sProblemDetails, which both sides already have; a hand-written copy would be a second definition of a format neither controls. ItstraceIdis emitted without configuration, which is what makes a detail-free 500 workable.Tests inject a throwing middleware via a startup filter rather than mapping a route that exists only to fail.
Notes
Criterion 3 ("exception details logged with correlation ID") was verified, not reimplemented — ADR-0015's work already does it.
TestServerrethrows rather than producing a200-logged response, so the deployed half of the old defect cannot be re-demonstrated through it; the Development test reproduces it verbatim. Recorded in ADR-0022.Possible ADR collision: this takes 0022. PR #17 is in flight — if it also claims 0022, one needs renumbering.
Verdict: changes needed
Independent review. Everything below was run against
3ac7686in a detached worktree, not read off the diff.What I executed
All four CI commands, on the pristine branch, Release, twice (before and after my experiments):
Then the API itself,
dotnet run, with temporary throwing routes, inDevelopmentand inProduction(JSON console formatter), againstAccept: application/json,Accept: text/html, a browser-shapedAccept, and noAcceptat all, each carrying a knowntraceparent.The central claim holds
Commenting out
app.UseExceptionHandler();fails 11 of 15, not 10 — every test inExceptionHandlingTests. One fails with exactly the ADR-0015 symptom:and the deployed-configuration tests fail on the rethrow, as ADR-0022 predicts. So the ordering claim is real and the tests pin it. (The
10in the description is worth correcting, since the description invites the check.)Running for real, both environments, the logged status is now the delivered status:
Nothing leaked in any of the four
Acceptshapes, in either environment: the body is{type,title,status,traceId}and nothing else. ANotFoundExceptionproduced a 404 with itsdetailand noErrorrecord, only the request log at 404 — so the suppression does what it says, and a genuine 500 still logs atErrorwith a stack trace under the caller's trace id. Ticket #58's three criteria are met.Blocking
1.
NotFoundException+Accept: text/htmldelivers 500, logs 404, and defeats the suppressionObserved, Production,
curl -H 'Accept: text/html':ErrorrecordsValidationFailedExceptionConflictExceptionNotFoundExceptionThe 404 case only.
TryWriteAsyncdeclines, so the handler returnsfalsehaving already setResponse.StatusCode = 404; the middleware's fallback path then hitsAllowStatusCode404Response(false by default) and throws:Kestrel logs that at
Error, the middleware logs theNotFoundExceptionatErrorwith a stack trace despiteSuppressDiagnosticsCallback, and the request log records 404 while the caller receives 500.That last part is the same class of defect this PR exists to remove — the request log disagreeing with what the caller got — reappearing in a corner. It also makes this consequence in ADR-0022 wrong as written:
True for the unhandled 500; false for an
ApiException. Since an accepted ADR's body is immutable under ADR-0001, this needs correcting before it is frozen.The fix I verified is one line in
ApiExceptionHandler— the handler did handle it, so say so, and treat an unwritten body as the writer's business:With that,
Accept: text/htmlgives a clean 404 with an empty body, noErrorrecords, and a request log that agrees with the caller; the unhandled 500 path is unchanged. A test forAccept: text/htmlwould have caught this and is the obvious thing to add — it is the oneAcceptshape ADR-0022 calls out and the only one no test covers.2. ADR-0022's rejection of a
Contractstype rests on a claim that does not compilePlaceMark.WebUIdoes not. It is aMicrosoft.NET.Sdk.BlazorWebAssemblyproject referencingMicrosoft.AspNetCore.Components.WebAssemblyandPlaceMark.Contracts, with noFrameworkReferencetoMicrosoft.AspNetCore.App. Dropping a one-line probe into it:I agree with the decision — nothing belongs in
Contractstoday, when nothing consumes a problem body — but not with this reasoning, and the reasoning is what gets frozen. It matters because the alternative it dismisses may be the right answer later: when the typed HTTP client of ADR-0008 reads a failure, it either takes a dependency on an ASP.NET Core assembly for one DTO (on a payload ADR-0008 says to watch) or hand-writes a reader — and a hand-written reader inWebUIis precisely the "second definition" this section rejects, only in the worse place. On ADR-0009's terms that is an argument for a contract, not against one.Please either correct the claim and re-argue the rejection on grounds that hold (nothing consumes it yet; decide it alongside the typed client), or change the decision.
3. The developer exception page can be brought inside
UseHttpLoggingThis is the load-bearing premise of the whole Development argument, in ADR-0015, in ADR-0022 and in the description:
The middleware
WebApplicationregisters automatically is outside and cannot be moved. An explicitly registered one is not. Observed, Development:The page and a correctly logged 500. So the choice was never "the page or an honest local log" — that dichotomy is stated three times in this change and it is not the case.
The decision may well survive this. The two independent arguments for it are good ones and I would not want them weakened: one shape of failure exercised in every environment, and no
IsDevelopment()branch standing between a misconfiguration and a stack trace on the wire — and the page is, after all, a Development-only leak of exactly the kind the "message in Development only" alternative was rejected for. But this is the strongest alternative available and## Alternatives considereddoes not contain it. Add it and reject it on those grounds, so the next reader does not rediscover it and conclude the record was mistaken about the framework.Non-blocking
traceIdformat. The body carries the full W3C id,00-1111…1111-3e5614c94d5f0347-01; the logs carry the bare 32-hex trace id,TraceId:1111…1111. Someone handed a trace id by a user and pasting it whole into a log search finds nothing.ExceptionHandlingTestsusesShouldContainrather thanShouldBe, so it already knows this. One sentence in the README's Failing a request section saying which part to search for would close it; changing the emitted value would cost the "without configuration" property the ADR values, so I would not.docs/adr/file, so 0022 is uncontested and the caveat in the description can go.ProblemDetailsContext.Exception = apiExceptionhands the exception into the body-writing pipeline. Nothing reads it today, but it is the one thread by which a futureCustomizeProblemDetailscould render an exception — a mild tension with "there is no branch here that could be made to render an exception".CancellationToken, a client disconnect surfaces asOperationCanceledException, which this handler declines — so it becomes anErrorrecord and an attempted 500 for a request nobody is listening to. Worth a ticket rather than a change here.Judged as asked
Error, with the stack trace, under the trace id the body quotes.{type,title,status,traceId}and nothing from the exception, in both environments. The "no branch to widen" argument is worth the extrareturn false. The 404 interaction in blocking item 1 is a consequence of declining after setting a status, not of declining as such.Errorwith a full stack trace in both environments; 400/404/409 leave only the request log at the right status. The hole is item 1, where anApiExceptiongets logged atErroranyway. The ADR's "if that ever proves too thin, add anInformationrecord" is the right escape hatch.Contracts— agree with the outcome, reject the stated reason. See item 2.ThrowingStartupFilter— good. Appending insideConfigureputs it inside every middlewareProgram.csregisters, which is what makes the assertions mean anything, and it keeps a route that exists only to fail out of the API. I confirmed the pipeline shape from the failure stack trace:HttpLoggingMiddleware → ThrowingStartupFilter. Preferable to a[Conditional]endpoint or a test-onlyMapGet.Status/Date/Sourcepresent, supersession lines correctly omitted, all four required sections present with a genuine alternatives section. Index row reads| [0022](0022-problem-details-in-every-environment.md) | Answer every failed request with Problem Details, in every environment | Accepted |— correct, and the title matches the file's H1. British English is clean throughout; the twoCustomizehits are framework symbols.The code change is small, correct in its main line, and unusually well evidenced. Fix the 404/
text/htmlpath and the two ADR claims that observation contradicts, and this is mergeable.Verdict: mergeable
Re-reviewed at
43c1cdd. All three blocking findings are fixed, and I re-ran the evidence rather than taking the summary on trust. One recommendation below that I am not blocking on.Re-verified
Four CI commands at
43c1cdd, Release:restoreok,build0 warnings / 0 errors,test30 passed (19 Api, 10 Infrastructure, 1 WebUI),format --verify-no-changesexit 0.Handler commented out: 15 of 19 fail, exactly as stated; the 4 survivors are the pre-existing health and request-logging tests.
Blocking 1 — the 404 defect. Fixed, and pinned. Reverting only
return true;back to returning the writer's result fails exactly one test —Send_NotFoundExceptionToACallerThatWillNotTakeJson_DeliversTheStatusItLogs— which is the right one, since 404 is the only statusAllowStatusCode404Responseguards. Running for real withAccept: text/html, Production and Development, delivered status now equals logged status across the board:Errorrecordsapplication/jsontext/htmlNotFoundException,text/htmlValidationFailedException,text/htmlConflictException,text/htmlNo Kestrel rethrow, no
AllowStatusCode404ResponseInvalidOperationException, and the suppression is no longer bypassed. DroppingExceptionfrom theProblemDetailsContextis the right call and closes the nit I raised alongside it.Blocking 2 — the
Contractsreasoning. The rewritten alternative is accurate: I reproducedCS0234again at this head. Deferring on ADR-0009's own "ships empty, each type written alongside its consumer" terms is a better argument than the one it replaces, and it now states the cost a future decider needs rather than asserting a shared definition that does not exist. Accepted.Blocking 3 — the developer exception page. The correction is right and the rejection is now made on the correct grounds. I re-measured it, using the two-line reversal recipe the Consequences section prescribes (
UseDeveloperExceptionPage()under a Development guard, afterUseExceptionHandler()) — the recipe works as written, and the request log records 500 with the page in place. To a JSON client the page returns:titleis the exception type,detailis the message, andexception.detailscarries the whole stack trace — inapplication/problem+json, the same content type a deployed environment uses for a body that carries none of it. That is the decisive fact and the ADR is right to rest on it rather than on the log.traceId. Now byte-identical to the log'sTraceId, including the server-generated one when notraceparentis supplied — body808941eb1d37ec08af98f8360d52b6ac, logTraceId:808941eb1d37ec08af98f8360d52b6ac. It applies to every problem body, not just the handler's: aTypedResults.Problemprobe carried the bare id too. Tightening the test toShouldBeis the right move. TheHttpContext.TraceIdentifierfallback is better than it looks — in the configuration that suppresses theActivitythere is noTraceIdin the logs to match anyway, andTraceIdentifieris exactly what the scopes carry asRequestId, so the fallback stays searchable rather than becoming a dead id.Nothing leaks, re-checked at this head in both environments across
application/json,text/html,*/*and noAcceptheader:{type,title,status,traceId}for a fault, plusdetailfor a refusal, and nothing else. Genuine 500s still log atErrorwith a full stack trace.Recommendation, not blocking
ADR-0015 should gain a
Partially superseded byline. ADR-0022 says an accepted ADR "is frozen, so ADR-0015 keeps its text" and puts the correction here alone. That is right about the body and not about the metadata: ADR-0001 expressly permits editing "theStatus,Superseded byandPartially superseded byfields, adding whichever lines the template left out — and to the matching row in the index", and provides partial supersession for precisely this case, where the old record otherwise stays Accepted. The repo already does it — ADR-0002 carrieswith the index row reading
Accepted (partly superseded by 0017). ADR-0015 has neither, so a reader who arrives there first — fromCLAUDE.md, or from ADR-0022's own Context link — gets the false claim with no pointer to the correction, and ADR-0022's "anyone reading ADR-0015's consequences on this point should read this record next" has no way to reach them.I am not blocking because, unlike the ADR body, this does not freeze: status metadata stays editable indefinitely, so a follow-up commit fixes it as well as this one would. But it is two lines — one field on ADR-0015 naming the claim it displaces, one index row — and it is cheaper here than as a ticket.
Smaller observations
WillNotTakeJsontests pass against the pre-fix code. Only the 404 one is a regression test; the other three document the intended contract for statuses that never broke. That is worth having and I would keep them — noting it only so nobody assumes all four are load-bearing if the guard behaviour changes.OperationCanceledException— I agree with declining it. Nothing throws it today, there are no endpoints and no cancellation tokens, and this record's Source line commits every claim in it to observation. A speculative consequence in a frozen document is worse than a ticket raised when there is something to measure. Right call, and right reason.CustomizeProblemDetailswas the one thing I said I would not do, on the grounds that it costs the "emitted without configuration" property. Seeing it run, I was wrong to weigh it that way: it is a single expression, it applies uniformly to every problem body the API writes, and it converts the id from something a reader must edit before searching into something they can paste. Worth the line.Good response to the review — every finding was reproduced before it was fixed, and the ADR is now accurate about the thing it previously got wrong, including where it inherited the error. Mergeable.
Verdict: mergeable
Re-reviewed at
13afb13. As asked, I checked the supersession trail rather than re-running behaviour — but I did re-run the four CI commands, becauseProgram.csis in the diff.Program.csis comment-onlyOne line,
16 KB of stack trace→a page of stack trace. No behaviour change, so the verification at43c1cddstands. Confirmed green anyway at this head: build 0 warnings / 0 errors, 30 tests pass,format --verify-no-changesexit 0.The supersession trail is coherent in both directions
Partially superseded by: ADR-0022 — one claim in the Consequences below…43c1cddadds 9 lines and removes 0Accepted (partly superseded by 0022)Supersedes: ADR-0015 **in part** — its claim that … and nothing else.AcceptedChecks that actually could have failed, and did not:
"offers no way to bring it inside"is verbatim from that sentence, so a reader can find it by searching the page.See [The Development complication](#the-development-complication)matches the new### The Development complicationheading in the same file.Accepted (partly superseded by 0017)while 0017 reads plainAccepted, and 0015/0022 now mirror that pair.ADR-0001's requirements are met on both sides: the old record stays
Acceptedand gains the field naming the new ADR and the part replaced; the new ADR states precisely what it displaces and no more.The wrong reason is properly gone
Both places that justified the placement by "the record cannot be touched" now say the opposite and say why:
That is the correct reading of ADR-0001, it cites the ADR-0002/0017 precedent so the next person has a worked example, and the Consequences paragraph was rewritten to match rather than left contradicting the header. "A reader who arrives at ADR-0015 first is sent here by its header before they reach it" is now true, which it was not before.
Byte figures
Right call, and the replacement is better evidence than the numbers were.
several pages of rendered stack traceandcarrying the exception message and stack trace in the same body shape a deployed environment uses for a body that carries neither — both observed, with the exception's message present in eachis exactly what I measured independently, and unlike16,618it will still be true on someone else's machine. TheProgram.cscomment was brought into line too, which is the kind of thing that usually gets missed.One informational note
The reasoning for keeping the three sibling
WillNotTakeJsontests is in this thread, not in the tree — no test file changed at this head. I am not asking for it: the doc comment already onSend_ValidationFailedExceptionToACallerThatWillNotTakeJson_DeliversTheStatusItLogsexplains the trap and names the 404 as the case that broke, which is enough for a reader who wonders why three near-identical tests exist. Noting it only so the omission is deliberate rather than assumed.Nothing outstanding. Merge it.