Problem Details Errors skill for Codex

One error shape clients can branch on. Codex inventories how your API fails today, defines a catalog of stable problem type URIs, and routes every error through one mapper that emits application/problem+json: validation failures as JSON Pointer lists, domain conflicts with useful extension members, 429s with Retry-After, and 5xx bodies that carry a trace ID but no internals. Works with Express, Fastify, FastAPI, Spring Boot, ASP.NET Core and Go.

  • Backend & APIs
  • $problem-details-errors
  • 208 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- problem-details-errors

Installs to ~/.agents/skills/problem-details-errors/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $problem-details-errors, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Standardises HTTP API error responses on RFC 9457 Problem Details (application/problem+json, which replaced RFC 7807) - a catalog of problem type URIs, title/status/detail/instance usage, extension members for validation errors with JSON Pointers, trace IDs and retry hints, safe 5xx masking, and one central error mapper - in Express, Fastify, FastAPI, Spring Boot ProblemDetail, ASP.NET Core AddProblemDetails and Go. Use when API errors are inconsistent, clients parse error strings, adding validation error output, or documenting errors in OpenAPI.

Use it when

  • Error responses differ between endpoints or framework layers
  • Clients parse error message strings to decide what to do
  • Validation errors need field-level detail
  • Documenting error responses in an OpenAPI spec

Use something else when

  • You are deciding endpoints and status codes overall: use rest-api-design
  • The API is GraphQL, whose errors travel in the response errors array: use graphql-resolvers

How to check the result

curl a validation failure, an unknown route, malformed JSON and a forced exception: each returns application/problem+json with a known type, a matching status and no internal details.

The mistake it prevents

Converting domain errors but forgetting framework-generated ones such as 404 routing, body-parser and auth failures, which keep returning HTML or ad-hoc JSON.

~/.agents/skills/problem-details-errors/SKILL.md

--- name: problem-details-errors description: Standardises HTTP API error responses on RFC 9457 Problem Details (application/problem+json, which replaced RFC 7807) - a catalog of problem type… license: MIT ---

Done well means: every error the API returns, from validation to rate limiting to crashes, has the same application/problem+json shape; clients branch on a stable type URI, never on English text; validation errors point at the exact field; and nothing internal leaks out of a 5xx.

The shape

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: en

{
  "type": "https://api.example.com/problems/insufficient-credit",
  "title": "Insufficient credit",
  "status": 409,
  "detail": "This order costs 50.00 but the account has 30.00 available.",
  "instance": "/orders/attempts/7f3c2a",
  "balance": "30.00",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
MemberRule
typeURI identifying the problem kind. Stable forever; clients switch on it. Should resolve to human docs. Omitted means about:blank
titleShort summary of the type. Same for every occurrence (may be localised). With about:blank, use the HTTP reason phrase
statusMust equal the actual HTTP status code. Advisory copy for clients that lose headers
detailThis occurrence, written to help the client fix it. No stack traces, SQL, or internal hostnames
instanceURI reference for this occurrence (request ID path or log lookup), optional
extensionsExtra top-level members (errors, balance, traceId). Clients must ignore unknown ones

Build the catalog first

  1. Read AGENTS.md, then inventory current errors: rg -n "status\((4|5)\d\d\)|HTTPException|ResponseStatusException|http.Error|res.status" src.
  2. Group them into problem types. Typical set:
type suffixStatusWhen
validation-error422 (or 400 for malformed JSON)Body or params fail schema validation
malformed-request400Unparseable JSON, wrong content type
unauthenticated401Missing or invalid credentials (send WWW-Authenticate)
forbidden403Authenticated but not allowed
not-found404Resource absent or hidden from this caller
conflict / domain-specific (email-taken, insufficient-credit)409State conflict the client can resolve
precondition-failed412If-Match ETag mismatch
rate-limited429With Retry-After header
internal-error500Unexpected failure; generic detail plus traceId
service-unavailable503Maintenance or dependency down; Retry-After when known
  1. Prefer specific domain types for errors clients act on differently (email-taken vs username-taken). Use generic about:blank only where the status code says everything.
  2. Publish each type as a short docs page at its URI and list them in the OpenAPI spec. Type URIs are identifiers: never rename one; add a new type instead.

Validation errors

Use one validation-error type with an errors array of JSON Pointers into the request:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "2 fields are invalid.",
  "errors": [
    { "pointer": "#/email", "detail": "must be a valid email address" },
    { "pointer": "#/items/0/quantity", "detail": "must be at least 1" }
  ]
}

Use "parameter": "page" or "header": "If-Match" instead of pointer for query and header problems. Return all field errors in one response, not the first one.

One central mapper

Throw typed errors in domain code; convert to problem+json in exactly one place.

TypeScript (Express 5 / Fastify)

export class Problem extends Error {
  constructor(
    public type: string, public status: number, public title: string,
    public detail?: string, public extensions: Record<string, unknown> = {},
  ) { super(detail ?? title); }
}
const BASE = "https://api.example.com/problems/";

export function toProblem(err: unknown, traceId: string) {
  if (err instanceof Problem) {
    return { status: err.status, body: { type: BASE + err.type, title: err.title, status: err.status,
             ...(err.detail && { detail: err.detail }), ...err.extensions, traceId } };
  }
  if (isZodError(err)) {
    return { status: 422, body: { type: BASE + "validation-error", title: "Request validation failed",
             status: 422, errors: err.issues.map(i => ({ pointer: "#/" + i.path.join("/"), detail: i.message })), traceId } };
  }
  return { status: 500, body: { type: BASE + "internal-error", title: "Internal Server Error",
           status: 500, detail: "An unexpected error occurred.", traceId } };  // never err.message
}

// Express 5: async errors reach this handler automatically
app.use((err, req, res, _next) => {
  const { status, body } = toProblem(err, req.traceId);
  if (status >= 500) req.log.error({ err, traceId: req.traceId }, "unhandled error");
  res.status(status).type("application/problem+json").json(body);
});

In Fastify, do the same in setErrorHandler and in setNotFoundHandler so 404s match. Escape ~ and / inside path segments (~0, ~1) when building pointers from keys that may contain them.

FastAPI

from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

PROBLEM = "application/problem+json"

@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
    errors = [{"pointer": "#/" + "/".join(str(p) for p in e["loc"][1:]), "detail": e["msg"]}
              for e in exc.errors() if e["loc"][0] == "body"]
    params = [{"parameter": str(e["loc"][-1]), "detail": e["msg"]}
              for e in exc.errors() if e["loc"][0] in ("query", "path")]
    return JSONResponse(status_code=422, media_type=PROBLEM, content={
        "type": f"{BASE}validation-error", "title": "Request validation failed",
        "status": 422, "errors": errors + params, "traceId": request.state.trace_id})

Also register handlers for StarletteHTTPException (covers 404/405) and Exception (500).

Spring Boot 3

spring.mvc.problemdetails.enabled=true
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler {
  @ExceptionHandler(InsufficientCreditException.class)
  ProblemDetail credit(InsufficientCreditException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    pd.setType(URI.create("https://api.example.com/problems/insufficient-credit"));
    pd.setTitle("Insufficient credit");
    pd.setProperty("balance", ex.balance().toPlainString());
    return pd;
  }
}

ASP.NET Core

builder.Services.AddProblemDetails(); then app.UseExceptionHandler(); app.UseStatusCodePages();. Customise via AddProblemDetails(o => o.CustomizeProblemDetails = ctx => ...) to add traceId, and return TypedResults.Problem(...) for domain errors. Note that built-in ValidationProblemDetails emits errors as a { field: [messages] } dictionary; either keep that shape consistently or map it to the pointer array, but do not mix both.

Go

Write a Problem struct with json:"type" etc. and a single writeProblem(w, p) that sets Content-Type: application/problem+json before WriteHeader. Use map[string]any merging for extensions.

Workflow checklist

  • Inventory existing error responses and their clients
  • Define the problem type catalog with statuses and docs URIs
  • Add typed domain errors and one central mapper per service
  • Map framework errors too: validation, 404/405 routing, body-parser, auth middleware, 429
  • Mask 5xx details; include traceId; log the real error server-side
  • Document problem responses in OpenAPI (content: application/problem+json)
  • Coordinate with client teams if the old error shape is consumed; keep both during a deprecation window (api-versioning)

Verification

curl -si -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"items":[{"quantity":0}]}'
curl -si localhost:3000/does-not-exist
curl -si -X POST localhost:3000/orders -H 'content-type: application/json' -d '{bad json'

Each must show Content-Type: application/problem+json, a body status equal to the HTTP status, a known type, and for 5xx no exception text. Add contract tests that assert the shape for one error of every type, and a test that forces an unexpected exception and checks the body contains only the generic detail and a traceId.

Gotchas

  • status in the body disagreeing with the HTTP status after a proxy or middleware rewrite.
  • Framework defaults (404 pages, body-parser errors, auth middleware) still returning HTML or their own JSON. Hit them explicitly in tests.
  • Clients matching on title or detail, which you will reword. Tell them to use type.
  • Putting user-supplied input into detail unescaped; it may be rendered in someone’s UI.
  • 404 vs 403 for resources in another tenant: prefer 404 so existence is not confirmed.
  • For the status-code choices themselves, use rest-api-design.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Problem Details Errors"
  short_description: "One error shape clients can branch on"
  default_prompt: "$problem-details-errors Make every error in this API return RFC 9457 problem+json"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc