feat(poracle): understand PoracleNG's problem+json errors ahead of the v2 move - #811
Conversation
…e v2 move
PoracleNG 5.2.1 answers its new /api/v2 with RFC 9457 application/problem+json and
refuses invalid input with 422 rather than 400.
This changes nothing for anyone today, and the first version of this commit said
otherwise. Verified against a real 5.2.1 server (dev-01 :3042): the frozen v1 surface
this application actually calls still answers {"message":"...","status":"error"} at
400, exactly as 5.1.0 does. The new format is confined to v2, which we do not yet
speak. This is groundwork for #805, not a live regression fix.
It is still worth doing before that migration rather than during it. Without it, the
first endpoint moved to v2 would match only BadRequest, send every 422 through
EnsureSuccessStatusCode, and report the caller's own mistake as 500 "An unexpected
error occurred" while logging it as a server fault -- the exact path #539 fixed.
Extraction moves to PoracleErrorMessage, which reads both shapes by field name rather
than branching on a version or a content type. The names do not collide, so one reader
serves both, and a reverse proxy rewriting Content-Type cannot cost a user their error
message. problem+json errors[] names the individual fields that were refused, which the
old format could not do, so it is preferred over the detail summary. A numeric status is
never returned as the message: the old shape put a word there, the new one puts the HTTP
code, and answering a user with "422" explains nothing.
CreateAsync now also treats 422 as the caller's problem, so the v1 and v2 paths agree.
One behaviour change beyond that: a body that parses as JSON but carries no explanation
falls back to a plain sentence instead of being echoed. The raw echo is for bodies that
are not JSON at all, such as a proxy's "upstream connect error"; raw JSON in a snackbar
tells a user less than a sentence does.
Four tests carry payloads captured verbatim from the live 5.2.1 instance, including the
v1 rejection from the same server, which is the evidence that both shapes must stay
readable.
PoracleHumanProxy has the same 400-becomes-500 gap on its write paths. That predates
5.2.1 and is left for its own change.
Refs #803
d8f2978 to
401576b
Compare
|
Corrected the premise of this PR after standing up a real 5.2.1 server. I had claimed 5.2.1 breaks error reporting on prod. It does not. PoracleNG's v1 surface is frozen and still answers The code is unchanged and still worth merging — it is a prerequisite for #805 rather than a fix — but the title, body, commit message and changelog entry all said something untrue and now say what I verified. The changelog entry moved from Fixed to Changed and states plainly that nothing changes for users today. Four tests now carry payloads captured verbatim from the live instance, including the v1 rejection from the same server. |
* feat(pokemon): write edits through PoracleNG's /api/v2 surface (#805) Pilot the strict /api/v2 tracking surface on the pokemon UPDATE path only. `PUT /api/v2/humans/{id}/tracking/pokemon/{uid}` is addressed by uid: PoracleNG 404s a uid the human does not own and 409s a replacement that would duplicate another rule, so the server enforces what TrackingUpdateReconciler had to reconstruct from a 200. Reads, creates, bulk creates, both distance endpoints and all nine other alarm types stay on v1, which 5.2.1 leaves frozen. Reads stay on v1 deliberately: v2 returns null where v1 returns the wildcard sentinel, and both Monster models are built on the sentinels. The v2 PUT is delete-then-insert, so pokemon now rotates its uid on edit like the other nine types. MonsterService therefore takes ITrackedUidRemapper and TrackedUidRemapperCoverageTests lists it among the rotating services; the fact asserting the opposite fired exactly as designed and has been moved, not weakened. TrackingV2Translator is the single exit onto v2. It splits the clean bitmask into clean/edit/summary booleans, maps gender 0-3 to the enum, drops uid, id, profile_no, ping and description (V2PokemonRule sets additionalProperties:false, so one stray property is a 422), and answers false for anything it cannot carry faithfully so the row takes the v1 path rather than failing. Gated on PoracleServerProfile.SupportsV2Tracking (version >= 5.2.0; /health advertises no capability for it), overridable with Poracle:TrackingApiVersion / PORACLE_TRACKING_API_VERSION, and with a runtime fallback to v1 when the route answers gin's plaintext 404. PoracleProblemDetails reads RFC 9457 problem+json at 422 in both its shapes plus v1's {"message":...}. Duplicates work in unmerged PR #811; collapse on merge. Every wire shape here was verified against the live 5.2.1 instance, not read from source. No frontend change: every pokemon mutation already reloads the list, so the uid rotation is invisible. * fix(pokemon): keep a set ping off the v2 write path V2PokemonRule has no ping field and the 5.2.1 handler stores Ping: "" unconditionally, so translating a rule that carries one discarded it. Verified live against the 5.2.1 copy: a rule holding <@&400027130022592512> came back with an empty ping after one v2 PUT. Two production rules carry a ping and both are webhook alarms, where the role mention is the point of the alert -- and both are reachable from the web UI through webhook delegation. ping now leaves the unconditional drop list and joins the fields the translator refuses to carry: empty or null is still dropped (v2 would store the same empty string, so the other 18488 rules stay on v2), a set one sends the row to v1, which keeps it. Same idiom the translator already uses for an out-of-range gender or an unmodelled clean bit.
…on-errors # Conflicts: # CHANGELOG.md
#805 landed PoracleProblemDetails on the v2 write path while this branch was open, and it is the same reader this branch introduced as PoracleErrorMessage -- same design, same 300-character cap, same fallback string, arrived at independently. Merging both would have shipped two implementations of one idea into develop, which is how one of them ends up being the one nobody updates. PoracleErrorMessage is deleted. The v1 create path now calls PoracleProblemDetails.Describe, so v1 and v2 explain a refusal the same way and there is one place to fix a wording bug. The 400-or-422 widening stays: v1 does not emit 422 today, but the two paths agreeing costs nothing. Two changes to the surviving reader: Field errors are capped at three with a count of the rest. A body refused on a dozen fields rendered a dozen clauses into a snackbar. A string `status` is no longer read as the message. This branch had it in the precedence list; PoracleProblemDetails does not, and its omission is right -- v1's status is the word "error", so surfacing it is worse than the honest fallback. The test asserting the old behaviour is dropped rather than carried, because it was asserting something undesirable. The seventeen cases move onto Describe, including the four captured verbatim from a live 5.2.1 server. PoracleProblemDetails stays internal; the test project reaches it through InternalsVisibleTo, matching the API project. Refs #803
|
Reworked after #814 merged, because it changed what this PR should be. #814 landed So Two improvements to the surviving reader, both carried over from this branch's tests:
The seventeen cases moved onto Backend 2345 passing. |
Groundwork for #805. Refs #803.
What is actually true
PoracleNG 5.2.1 answers its new
/api/v2with RFC 9457application/problem+jsonand refuses invalid input with 422. Its v1 surface is frozen and unchanged — and v1 is all this application speaks.Verified against a real 5.2.1 server (a new dual-version instance on dev-01,
:3042, alongside the existing 5.1.0 on:3040):Same server, both shapes. So nothing is broken on prod, and nothing in this PR changes behaviour for any user today.
Why do it now anyway
Because of what happens on the first endpoint that moves to v2.
CreateAsyncmatched onlyBadRequest, so a 422 would fall through toEnsureSuccessStatusCode— the exact path #539 fixed. TheHttpRequestExceptionis flattened into 500 "An unexpected error occurred", the user is told the server broke rather than what was wrong with their alarm, and it is logged as a fault. AndExtractMessageAsynclooked formessage,errororstatusas strings; problem+json has none of those, since itsstatusis the HTTP code.Cheaper to get right before the migration than to debug during it.
Approach
Extraction moves to
PoracleErrorMessage, which reads both shapes by field name rather than branching on a version or a content type. The names do not collide, so one reader serves both, and a reverse proxy rewritingContent-Typecannot cost a user their error message.errors[]is preferred overdetail: it names the individual fields refused, which the old format could not do.locationarrives asbody[0].pokemon_id, so only the trailing segment is shown.titleis the status phrase, so it answers only when nothing better is present.statusis never the message. The old shape put a word there, the new one puts the HTTP code.message,error, then a stringstatus.CreateAsynctreats 422 as the caller's problem, so v1 and v2 agree.One behaviour change beyond that: a body that parses as JSON but carries no explanation now falls back to a plain sentence instead of being echoed. The raw echo is for bodies that are not JSON at all.
Tests
2149 passing. Four carry payloads captured verbatim from the live 5.2.1 instance, including the v1 rejection from that same server — which is the evidence that both shapes have to stay readable.
The two proxy tests were confirmed red against the previous code before the fix went in, and the existing 400 test stayed green throughout.
Left out
PoracleHumanProxyhas the same 400-becomes-500 gap on its write paths. It predates 5.2.1 and spans fifteen call sites with differing caller expectations, so it wants its own change.The per-field mapping onto form controls that #803 describes is a frontend change and is not here.
errors[].locationis parsed and surfaced, so the data is ready when that lands.