Back to Meshery

HTTP Error Response Contract

docs/content/en/project/contributing/error-contract.md

1.0.638.0 KB
Original Source

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 ./server Go module by a forbidigo lint rule that blocks any new http.Error call 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.yml for 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 call http.Error at all (they emit error events through the active SSE writer instead); and binary/tar/YAML downloads and HTTP redirects, which use w.Write or http.Redirect rather than http.Error and so are not governed by the lint rule.

Shape

json
{
  "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.

Fields

FieldRequiredNotes
erroryesUser-facing message. For MeshKit errors, this is the ShortDescription.
codewhen availableMeshKit error code (e.g. meshery-server-1448). Stable across releases. Use for telemetry, i18n lookup, and programmatic handling.
severitywhen availableOne of EMERGENCY, ALERT, CRITICAL, FATAL, ERROR.
probableCauseoptionalArray of strings, one per cause.
suggestedRemediationoptionalArray of strings, one per step. Surface to users when present.
longDescriptionoptionalArray 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.

Client contract

  • Do not rely on plain-text error bodies — they are always JSON.
  • When code is present, prefer it over string matching on error.
  • When suggestedRemediation is non-empty, surface every entry alongside error.
  • When the body is not valid JSON, treat the response as a bug and report the offending endpoint; do not attempt a text fallback.

Producing errors in handlers

Use writeMeshkitError(w, err, status) in server/handlers/utils.go:

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.

Two rules that are easy to get wrong

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:

go
// 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.

Streaming JSON responses

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:

go
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.

  • File-level allowlist in .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 contract
    • ui_handler.go — serves static HTML; any http.Error call would relate to asset resolution, not API surface
  • Doesn't call http.Error at all — these sites emit non-JSON for legitimate reasons but use other write mechanisms, so the lint rule does not apply:
    • SSE stream handlers (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.
    • Binary/tar/YAML downloads use w.Write(bytes) with the appropriate Content-Type.
    • HTTP redirects use http.Redirect.