docs/content/en/project/contributing/error-contract.md
Every non-2xx HTTP response from Meshery Server carries a JSON body with
Content-Type: application/json; charset=utf-8. Clients should parse the body
as JSON before surfacing errors to users.
Migration status: the JSON contract is enforced across the
./serverGo module by aforbidigolint rule that blocks any newhttp.Errorcall at CI time, configured in.github/.golangci.yml. The migration landed in PR #18919 and the lint guard was flipped from advisory to fully blocking in PR #18927. Legitimate non-JSON responders are handled in one of three ways: a file-level allowlist in.github/.golangci.ymlfor the Kubernetes healthz probe handler (k8s_healthz_handler.go) and the static-asset UI handler (ui_handler.go); SSE stream handlers (events_streamer.go,load_test_handler.go) that don't callhttp.Errorat all (they emit error events through the active SSE writer instead); and binary/tar/YAML downloads and HTTP redirects, which usew.Writeorhttp.Redirectrather thanhttp.Errorand so are not governed by the lint rule.
{
"error": "Unable to create the environment",
"code": "meshery-server-1448",
"severity": "ALERT",
"probableCause": [
"Your account does not have permission to create environments in this organization.",
"An environment with the same name already exists in this organization."
],
"suggestedRemediation": ["Confirm your role in the selected organization."],
"longDescription": ["Full technical details suitable for logs."]
}
Field names are camelCase, matching the repo-wide wire convention (see
AGENTS.md, "Identifier Naming Conventions"). The three detail fields are
arrays with one entry per distinct cause or step - do not concatenate them
into a single string; the UI renders each entry as its own bullet.
| Field | Required | Notes |
|---|---|---|
error | yes | User-facing message. For MeshKit errors, this is the ShortDescription. |
code | when available | MeshKit error code (e.g. meshery-server-1448). Stable across releases. Use for telemetry, i18n lookup, and programmatic handling. |
severity | when available | One of EMERGENCY, ALERT, CRITICAL, FATAL, ERROR. |
probableCause | optional | Array of strings, one per cause. |
suggestedRemediation | optional | Array of strings, one per step. Surface to users when present. |
longDescription | optional | Array of strings. Suitable for developer logs; may contain stack-style detail. |
Fields marked "when available" are omitted (via omitempty) for errors that
originated outside the MeshKit error catalog.
code is present, prefer it over string matching on error.suggestedRemediation is non-empty, surface every entry alongside error.Use writeMeshkitError(w, err, status) in server/handlers/utils.go:
resp, err := provider.SaveEnvironment(req, &environment, "", false)
if err != nil {
handlerErr := ErrSaveEnvironment(err)
h.log.Error(handlerErr)
writeMeshkitError(w, handlerErr, providerStatus(err))
return
}
For bare-string errors without a MeshKit code, use writeJSONError(w, msg, status).
Every bare-string error is a candidate for promotion to a MeshKit error -
prefer adding a code when fixing an adjacent bug.
Use an error code that belongs to the operation. Reusing an unrelated
component's code defeats the point of having codes at all. The environment and
workspace handlers spent a long time reporting every failure as
ErrGetResult / meshery-server-1033 - a performance-results code whose
probable cause reads "Result Identifier provided is not valid. Result did not
persist in the database". A real production log for a failed environment save
read Status Code: 403 .failed to save the environment | ... unable to get result | Probable Cause: Result Identifier provided is not valid..., sending
maintainers into the wrong subsystem. Add a code for your operation; see
Contributing to Meshery Error Codes.
Never hardcode the HTTP status of a provider failure. Those same handlers
answered every failure with http.StatusNotFound, so a remote-provider 403
reached the browser as a 404 - a response the UI did not recognise as a failure
at all, which is how a rejected "Create Environment" produced a success toast.
models.ErrFetch and models.ErrPost tag the error with the status the
provider actually returned (httputil.WithProviderStatus). Handlers read it
back with the providerStatus helper:
// server/handlers/utils.go
func providerStatus(err error) int {
return httputil.StatusForProviderError(err, http.StatusBadGateway)
}
The fallback is 502 Bad Gateway, not 404: when the provider produced no
status of its own the failure is still an upstream one, and "not found" is a
claim about the resource for which there is no evidence. Choose a different
fallback only when the handler genuinely knows better.
Do not use http.Error in handlers or provider code. It writes
Content-Type: text/plain and strips MeshKit metadata, which crashes
RTK Query's default baseQuery on the UI.
When a handler writes a JSON body via json.NewEncoder(w).Encode(...),
encoding into the ResponseWriter directly commits headers + status before
the encoder errors. If the encode fails partway, you cannot emit a fresh
error response — the wire already has a partial JSON envelope and a 200 OK
status. Buffer first, write once:
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
// No headers committed yet — safe to emit a fresh error response.
writeMeshkitError(w, models.ErrEncoding(err, "<object name>"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(buf.Bytes()); err != nil {
// Client likely disconnected; headers already committed, can't emit
// a new error. Log and move on.
h.log.Warn(fmt.Sprintf("write response: %v", err))
}
The provider layer (server/models/remote_provider.go,
server/models/default_local_provider.go) follows this pattern — see commit
ed1ce9f25c for reference call sites.
Legitimate exceptions, organised by how each is enforced. The forbidigo
lint rule (in .github/.golangci.yml)
matches http.Error codebase-wide; some non-JSON responders sidestep it by
not calling http.Error at all, while two files are exempted via a
file-level allowlist. See PR
#18919 (migration) and
#18927 (lint guard
blocking) for context.
.github/.golangci.yml — entire file is exempt
because plain text is the contract there, not an internal detail:
k8s_healthz_handler.go — Kubernetes healthz probe contractui_handler.go — serves static HTML; any http.Error call would relate
to asset resolution, not API surfacehttp.Error at all — these sites emit non-JSON for
legitimate reasons but use other write mechanisms, so the lint rule does
not apply:
events_streamer.go, load_test_handler.go) emit
error events through the active SSE writer (Content-Type: text/event-stream) rather than http.Error, which would corrupt the
stream by trying to re-set headers after they have been flushed.w.Write(bytes) with the appropriate
Content-Type.http.Redirect.