Skip to content

feat: one error object with a code, message and details map - #338

Merged
Taure merged 1 commit into
mainfrom
feat/error-object
Aug 4, 2026
Merged

feat: one error object with a code, message and details map#338
Taure merged 1 commit into
mainfrom
feat/error-object

Conversation

@Taure

@Taure Taure commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What was broken

asobi reported failures in four incompatible dialects, so nothing could branch on a failure programmatically:

Surface Shape Example
REST #{error => Binary} src/controllers/asobi_storage_controller.erl:43 (pre-change)
REST bare status, empty body asobi_storage_controller.erl:117,119,121,125 (pre-change) - a 403 and a 404 that say nothing
REST #{error => Binary, current_version => N} asobi_storage_controller.erl:52 (pre-change) - ad-hoc extra keys at the top level
WebSocket #{reason => Binary} src/ws/asobi_ws_handler.erl:647 (pre-change), 38 call sites

Nothing was namespaced (not_found from storage and not_found from a world were the same string), nothing was enumerated, and a client could not tell an empty-bodied 403 from an empty-bodied 500.

What changed

src/asobi_error.erl (new) - one object:

{"error": {"code": "storage.not_found", "message": "No object exists at this collection and key.", "details": {}}}
  • code is the contract: machine-readable, namespaced by domain (storage., save., match., world., chat., matchmaker.) or bare when cross-cutting (rate_limited, internal). The set is closed - codes/0 enumerates it. A string supplied by a client or a Lua game script can never become a code; it lands in details instead.
  • message is prose for a human reading a log, explicitly not for parsing.
  • details is always a map, #{} when empty, so no client needs a null branch.
  • Each code carries its HTTP status, so a controller states the failure and never the number.

Nova return handler - asobi_error:handle/3, registered in asobi_app:start/2. Controllers return:

{asobi_error, ~"storage.not_found"}
{asobi_error, ~"save.version_conflict", #{current_version => 4}}
{asobi_error, Status, Code, Details}   %% explicit status when the code does not imply it

It delegates the encode to nova_basic_handler:handle_json/3, so content-type, status, and the configured json_lib (OTP json) stay exactly as every other JSON response. An undefined code returns 500 and logs undefined_error_code rather than failing silently.

WebSocket (src/ws/asobi_ws_handler.erl) - all 38 error sites now funnel through one encode_error/2,3. This is additive: reason is unchanged byte-for-byte and still sent, and error is added next to it. asobi_error:from_ws_reason/1 maps the legacy reason onto a code, so reason: "invalid_token" now also carries code: "unauthenticated". An unmapped reason becomes ws.request_failed with the raw string in details.reason.

REST worked example (asobi_storage_controller.erl) - all 13 failure returns converted, including four that previously had no body at all. The other 73 routes are untouched and keep their shapes.

Also updated: priv/protocol/fixtures/error.json (SDK dispatch-test ground truth - it would otherwise pin a shape the server no longer sends), guides/rest-api.md (new Errors section, with an explicit rollout note that only /saves and /storage return this shape today), guides/websocket-protocol.md (new error frame section).

Follow-up, deliberately not in this PR

Converting the remaining 73 routes across the other 15 controllers. Per the brief the shape matters more than the coverage here, since this is the shared prerequisite for both the ops API and the RPC wire. The old shapes keep working until then, and guides/rest-api.md says so in the docs rather than leaving clients to discover it.

Two things that shape review of that follow-up:

  • Converting a route does change its body for clients that read error as a string, because the new object occupies the same error key. There is no additive option for REST the way there is for WebSocket. HTTP status codes are unchanged throughout, so a client that branches on status is unaffected.
  • The four {status, N} → error-object conversions in storage turn previously empty-bodied responses into JSON. That is the point (a 403 you can branch on), but it is a body where there was none.

What the tests assert

test/asobi_error_tests.erl (24 tests, new):

  • The object always has exactly code, message, details - and details serialises as {}, never null.
  • Status comes from the code (404/429/409/503 spot-checked); an undefined code is a 500 with a distinct message, not a crash.
  • Codes are unique and wire-safe (lowercase ASCII, at most one dot).
  • Every entry in the ws reason→code table resolves to a code that actually exists - this is the test that catches a typo in the mapping before it reaches a client.
  • An unmapped ws reason produces ws.request_failed with the raw string in details and cannot mint a code.
  • priv/protocol/fixtures/error.json matches what from_ws_reason/1 actually emits, so the SDK fixture cannot drift from the server.
  • The handler: status from the code, details reaching the body, explicit-status override, and a real JSON body with content-type: application/json.

test/asobi_ws_handler_tests.erl (5 new tests) - drives real frames through websocket_handle/2:

  • Oversized frame, invalid JSON, and unknown type all carry reason and error with the right code and an empty details map.
  • cid still round-trips on an error reply.
  • reason: "invalid_token" with code: "unauthenticated" - the case that proves the legacy string survives while the code deliberately differs from it.

test/asobi_storage_SUITE.erl (4 assertions strengthened) - end to end against Postgres:

  • 409 version conflict now asserts code: "save.version_conflict" and details.current_version == 2 (the flat shape put current_version at the top level next to error).
  • 413 asserts code: "storage.value_too_large".
  • The 404 after a delete and the 403 cross-owner read assert real bodies - both were empty before.

Checks

Command Result
rebar3 fmt --check pass
rebar3 xref pass
rebar3 eunit 594 tests, 0 failures
rebar3 dialyzer pass
rebar3 ex_doc pass, no warnings
rebar3 ct --suite=test/asobi_storage_SUITE 14/14 pass against Docker Postgres

What I could not verify

rebar3 ct --suite=test/asobi_ws_SUITE fails 8/8 on this machine. This is pre-existing and environmental, not this change: I reproduced it identically on a clean origin/main tree (git stash, re-run, same 8 failures). The CT log shows {listen_error, nova_listener, eaddrinuse} on the nova port plus Postgres 53300 sorry, too many clients already - another node on this host is holding port 8082 and the connection pool. asobi_storage_SUITE passed 14/14 earlier in the same session when the port was free, and re-running it after the contention started reproduced the same eaddrinuse. CI should run both cleanly; worth a look at the CI result before merge rather than trusting my local run.

I have also not verified the 7 client SDKs against the updated priv/protocol/fixtures/error.json - they live in separate repos. The fixture change is additive (the reason key they dispatch on is untouched), but an SDK doing an exact-match assertion on the whole payload would need a bump.

asobi reported failures in four incompatible dialects: REST returned a
flat #{error => Binary}, WebSocket returned #{reason => Binary}, some
routes returned a bare status with no body at all, and the admin surface
added more. Nothing could branch on a failure programmatically.

Add asobi_error: one object, #{error => #{code, message, details}}, with
a closed set of namespaced codes that carry their HTTP status, and a Nova
return handler so a controller states the failure rather than the number.
details is always a map so no client needs a null branch.

Convert the WebSocket error path (additive - `reason` is unchanged and
still sent) and asobi_storage_controller as the worked example. The other
73 routes keep their existing shapes and are converted in a follow-up.
@Taure
Taure force-pushed the feat/error-object branch from 7e9efcd to 5f285ff Compare August 4, 2026 00:18
@Taure
Taure merged commit a594670 into main Aug 4, 2026
15 checks passed
@Taure
Taure deleted the feat/error-object branch August 4, 2026 00:35
Taure added a commit that referenced this pull request Aug 4, 2026
* feat: finish the shared error object rollout across every route

asobi_error shipped in #338 with one worked example: 7 of 64 REST routes.
Every other controller still answered in its own dialect - a flat
so nothing outside /saves and /storage could branch on a failure.

Convert all of them, including the two controllers that live outside
src/controllers/ (players, votes), the ops read plane, asobi_ops_auth's deny
body, asobi_auth_tokens, and the three plugins that write their own 4xx/5xx
body without reaching a controller. Add the codes those failures need: one
namespace per domain, and a single code plus details.reason wherever the
reason is not ours to publish - an identity provider's rejection, a store's
receipt diagnostic, a game script's refusal to create a world.

Additive except for `error` itself, which was already a string and is now the
object. Every other top-level key a route sent survives untouched - `fields`,
`errors`, `retry_after`, `field`, `order`, `reason` - and is repeated in
`details` so new code reads one place. asobi_error:legacy/2 is the one funnel
for that. Statuses are unchanged.

Add asobi_error_contract_tests: it reads the abstract code of every module in
the application and fails on a flat error body, a bodiless 4xx/5xx, a code
outside the closed set, or a code that is not a literal - which is what stops
a client- or script-supplied string from ever becoming one. The scan derives
its own module set, so the next controller is covered whether or not anyone
remembers this test exists.

* fix(docs): teach the error-drift guard the shared error object

The guard extracted {json, Status, #{}, #{error => ~"code"}} from a
controller. Since this PR converts every controller to {asobi_error, Code},
it matched nothing on the source side and reported all sixteen documented
guest codes as undocumented - correctly failing, for the right reason.

A controller no longer names a status, so the guard resolves code -> status
from asobi_error's ?CODES table, the same way asobi_error:status/1 does. A
code with no entry reports 500 rather than being dropped, so an undefined
code cannot hide from the guard by being absent from the table.

Both regexes now accept the dot in a namespaced code.

The guest table in guides/authentication.md is rewritten to the codes the
controller actually returns, and its column header says error.code rather
than error, which is where a client now reads it. Three codes it never
documented are now listed: auth.registration_closed,
auth.password_registration_disabled and auth.username_taken, all reachable
on the upgrade path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant