Error responses (problem+json)
The management API (/api/v1/…, /api/admin/…) reports every error as an RFC 7807 problem-detail document — Content-Type: application/problem+json, one JSON object per error. There is one shared writer (api/internal/problem/problem.go) behind every handler in internal/handlers, so the shape below is uniform across the whole management surface.
Per-format wire protocols (/maven/…, /npm/…, /debian/…, the other adapters under internal/formats/, and Docker/OCI's /v2/…) are explicitly out of scope — they keep the error body their own protocol spec requires (e.g. Docker's {"errors":[{"code",...}]}), because the package manager on the other end parses that shape, not this one.
Shape
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "A retention policy with this name already exists in the repository.",
"instance": "/api/admin/retention/policies",
"request_id": "0e2d1a3c-...-...",
"error": "A retention policy with this name already exists in the repository."
}| Field | RFC 7807? | Meaning |
|---|---|---|
type | ✅ | A URI identifying the problem type. Always about:blank today — no handler defines a more specific type yet, so treat status + error as the machine-readable pair instead. |
title | ✅ | The standard HTTP status phrase (http.StatusText(status)), not customized per error. |
status | ✅ | HTTP status code, duplicated from the response line for JSON-only consumers. |
detail | ✅ | Human-readable message. Safe to render directly in UI. |
instance | ✅ | The request path that produced the error. |
request_id | Extension | Correlates the response with server-side logs/traces. Include it when filing a bug. |
error | Extension | Mirrors detail for the pre-RFC-7807 {"error": "..."} shape older call sites relied on. A small number of handlers (e.g. slug validation, CVE-policy promotion blocks — see below) instead set error to a stable machine-readable code so a CLI or the Terraform provider can switch on it without parsing English. |
Any additional top-level members beyond this list are handler-specific extensions, flattened onto the document by the same writer. Two examples already in the codebase:
- Slug validation (
internal/handlers/slug.go) — 400s carryerror: "invalid_slug"plusfield,message,examples, and an optionalsuggestion. See Naming conventions for the grammar these errors are validating against. - CVE-policy promotion block (
internal/handlers/promotions.go) — 409s carryerror: "cve_policy_block"plusreasonandhits(the blocking finding rows).
Don't rely on the presence of any extension member beyond request_id unless you already know the specific endpoint sets it — most handlers call the plain Write(w, status, detail) helper and emit only the fields in the table above.
Client-side parsing
The frontend's shared parser (frontend/src/api/problem.ts, parseErrorResponse) reads a failed fetch response and returns an ApiError whose .message is detail (falling back to title, then the raw body, then the status line) — so existing String(error) / error.message call sites read as a sentence instead of a bare status code. It's wired into frontend/src/api/client.ts's central fetcher and every direct fetch() call site under frontend/src/api/ and the admin form-submit pages, so any SWR hook's error already carries this shape.
import { parseErrorResponse } from './problem';
const res = await fetch('/api/v1/repos', { method: 'POST', body });
if (!res.ok) {
throw await parseErrorResponse(res);
}What's still the raw wire-protocol shape
AdminApiReference.tsx (the in-app API playground) intentionally shows the raw response body regardless of shape, and Login.tsx's 401/429 paths keep their own hardcoded copy — neither goes through parseErrorResponse. Outside internal/handlers, the per-format adapters and Docker/OCI push/pull paths also don't use this envelope; see their own protocol's spec for the error body they return.