feat(server): answer every API error with a JSON body - #6916
Merged
Conversation
Every 4xx and 5xx carried an empty body, so a client learned that something failed but never
what. The HTTP error handler now renders {"message": …}, plus {"fields": …} when the error names
the request fields it concerns.
A 5xx never echoes the error's own text: those messages describe internals, and only the reporter
sees them. Errors carrying an HTTP status read their message member rather than Error(), which
embeds the internal cause.
The eight handlers that hand-built their own bodies return the error instead, so the three ad-hoc
shapes become one. Four service methods move their conflicting field names onto the error and drop
the extra return value.
Fixes: #6915
Nine banners rendered the server's message, which was always empty, so a user who hit an error on the devices, containers, or sessions list, or on any of six admin pages, saw a blank banner. `apiErrorMessage` turns a caught value into display text keyed by status. It deliberately ignores the server's own message member: that text is for API clients and reads as internal phrasing here. `apiErrorFields` reads the per-field detail, which sign-up and accept-invite use to mark the field that is already taken.
|
Claude finished @otavio's task in 5m 3s —— View job Code Review CompleteReviewed 57 files across code quality, security, testing, language patterns, and architecture — no new issues found. The code looks good as-is. A few things worth noting as positives from the review:
To request another review round, comment |
luizhf42
approved these changes
Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every API error response now carries a JSON body:
{"message": …}, plus{"fields": …}when theerror names the request fields it concerns. The console stopped rendering server prose and shows
its own copy, keyed by status.
Why
Every 4xx and 5xx returned an empty body. A client learned that something failed but never what.
Nine error banners in the console rendered the server's message and therefore rendered nothing.
Eight handlers worked around the gap by building their own bodies, producing three different shapes
for the same kind of failure. The OpenAPI specification declared a message body for 401 on 109
operations and for 500 on 137, and the server sent none of them.
Closes #6915
Changes
A 5xx answers with a fixed generic message — the reporter still receives the real one, because
those texts describe internals. An error carrying an HTTP status reads its
Messagememberrather than
Error(), which embeds the internal cause;*echo.BindingErroris matchedexplicitly because it unwraps to its cause rather than to the
*echo.HTTPErrorit embeds.The layer switch gains a
defaultarm: thescopepackage declares a layer the switch does notname, which previously produced status zero.
RegisterUser,UpdateUser,CreateTagandUpdateTagcarry the conflicting field names onthe error and dropped the extra return value.
NewErrDuplicatedbuilds the field map, so cloud'sadmin duplicates gain bodies with no cloud-side change.
UpdateTagreported aTagConflictsstore failure as a 409 conflict.It now returns the store error, matching
CreateTag. This is the one status this PR changes.apiErrorschema. The threeoperations whose body shape changed point at the shared 400 and 409. The admin paths keep
invalidFields/conflictFields, because cloud's admin handlers still emit those arrays.apiErrorMessageandapiErrorFieldsinsrc/api/errors.ts. The ninebanners call the first. Sign-up and accept-invite read the field map through the second, so they
keep per-field highlighting. Neither reads the server's
message: that member is for APIclients, and reads as internal phrasing in the console.
Testing
The behaviour is pinned at two seams: the table in
server/api/pkg/echo/handlers/errors_test.go(19 cases, including the binding-error leak, anunrecognised layer, and the two paths that must not echo their own message) and
ui/apps/console/src/api/__tests__/errors.test.ts.Worth probing:
GET /api/devices?page=abc— a binding error must answer{"message":"failed to bind field value to int"}, never thestrconv.ParseIntcause.server/api/routes/nsadm_test.goguards that a 403 body leaks neither a namespace owner's nor amember's email. It is the reason the module renders the error's own message and never resource
detail.
messagemember has no console consumer by design. It exists for API clients and for thespecification's existing promise — it is not dead code.
Full suites pass: shellhub server (27 packages), cloud with
-tags enterprise(22 packages), andconsole (211 files).
golangci-lint, the console lint and build, redocly lint and prettier are allclean, and
go mod tidyleaves both modules unchanged.