Skip to content

feat(poracle): understand PoracleNG's problem+json errors ahead of the v2 move - #811

Merged
hokiepokedad2 merged 3 commits into
developfrom
fix/803-problem-json-errors
Aug 24, 2026
Merged

feat(poracle): understand PoracleNG's problem+json errors ahead of the v2 move#811
hokiepokedad2 merged 3 commits into
developfrom
fix/803-problem-json-errors

Conversation

@hokiepokedad2

@hokiepokedad2 hokiepokedad2 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Groundwork for #805. Refs #803.

Corrected. The first version of this PR claimed 5.2.1 breaks error reporting on prod today. That was wrong, and the change is now framed as what it actually is: preparation for the v2 migration.

What is actually true

PoracleNG 5.2.1 answers its new /api/v2 with RFC 9457 application/problem+json and 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):

5.2.1  POST /api/tracking/invasion/{id}     -> 400  application/json
       {"message":"Grunt type mandatory","status":"error"}

5.2.1  POST /api/v2/humans/{id}/tracking/pokemon -> 422  application/problem+json
       {"title":"Unprocessable Entity","status":422,"detail":"validation failed",
        "errors":[{"message":"expected integer","location":"body[0].pokemon_id","value":"twenty-five"}]}

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. CreateAsync matched only BadRequest, so a 422 would fall through to EnsureSuccessStatusCode — the exact path #539 fixed. The HttpRequestException is 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. And ExtractMessageAsync looked for message, error or status as strings; problem+json has none of those, since its status is 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 rewriting Content-Type cannot cost a user their error message.

  • errors[] is preferred over detail: it names the individual fields refused, which the old format could not do. location arrives as body[0].pokemon_id, so only the trailing segment is shown.
  • title is the status phrase, so it answers only when nothing better is present.
  • A numeric status is never the message. The old shape put a word there, the new one puts the HTTP code.
  • Old-shape precedence is unchanged: message, error, then a string status.
  • CreateAsync treats 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

PoracleHumanProxy has 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[].location is parsed and surfaced, so the data is ready when that lands.

@github-actions github-actions Bot added the fix label Aug 24, 2026
…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
@hokiepokedad2
hokiepokedad2 force-pushed the fix/803-problem-json-errors branch from d8f2978 to 401576b Compare August 24, 2026 03:12
@hokiepokedad2 hokiepokedad2 changed the title fix(poracle): read PoracleNG 5.2.1 problem+json errors, and treat 422 as a refusal feat(poracle): understand PoracleNG's problem+json errors ahead of the v2 move Aug 24, 2026
@hokiepokedad2

Copy link
Copy Markdown
Contributor Author

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 {"message":..,"status":"error"} at 400 on 5.2.1, and v1 is all this application speaks. The problem+json/422 change is confined to /api/v2. I took that from the release notes and the generated OpenAPI document, both of which describe the v2 surface, and did not check that the distinction mattered.

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.

hokiepokedad2 added a commit that referenced this pull request Aug 24, 2026
* 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.
#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
@hokiepokedad2

Copy link
Copy Markdown
Contributor Author

Reworked after #814 merged, because it changed what this PR should be.

#814 landed PoracleProblemDetails on the v2 write path — the same reader this branch introduced as PoracleErrorMessage. Same design, same 300-character cap, same "Poracle rejected the alarm." fallback string, arrived at independently. Merging both would have put two implementations of one idea into develop, which is exactly how one of them becomes the one nobody updates.

So PoracleErrorMessage is deleted and the v1 create path now calls PoracleProblemDetails.Describe. That is the real remaining gap: develop's v2 path reads the newer bodies and names the refused field, while v1 — still the path most installs use — was on the old string reader and matched only 400.

Two improvements to the surviving reader, both carried over from this branch's tests:

  • Field errors 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 and PoracleProblemDetails did not. Its omission is right: v1's status is the word "error", so surfacing it is worse than the fallback. I dropped that test rather than carrying it, since it asserted something undesirable.

The seventeen cases moved onto Describe, including the four captured verbatim from the live 5.2.1 server. PoracleProblemDetails stays internal; the tests reach it via InternalsVisibleTo, matching what the API project already does.

Backend 2345 passing.

@hokiepokedad2
hokiepokedad2 merged commit 8babe03 into develop Aug 24, 2026
6 checks passed
@hokiepokedad2
hokiepokedad2 deleted the fix/803-problem-json-errors branch August 24, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant