SKILL DETAIL
api-error-design
samber/developer-platform-skills/api-error-design
Design the error surface of a public API so integrators self-serve fixes - a machine-readable error-code taxonomy (flat catalog, code/subcode, Google-style domain/reason), the RFC 9457 problem-details envelope with extension members, actionable error messages, retryability signaling (retryable flag, Retry-After), and per-endpoint error documentation with request-ID tracking. Use whenever the user mentions API error codes, an error taxonomy, RFC 9457, application/problem+json, 4xx/5xx response bodies, or confusing API error messages - even if they never say "error design". Do NOT use for client-side retry mechanics - use samber/developer-platform-skills@api-idempotency-retry - nor for incident and status-page communication - use samber/developer-platform-skills@api-status-communication instead.
Installation
npx skills add https://github.com/samber/developer-platform-skills --skill api-error-design
技能文件
SKILL.md
最近同步 · 2026年10月23日
evals/evals.json›
{
"skill_name": "api-error-design",
"evals": [
{
"id": 1,
"prompt": "I'm the platform lead at Ledgerline. We expose a gRPC API (12 services, protobuf) to about 40 fintech partners. Errors today come back as a bare UNKNOWN status with a prose string. I want to build a proper error-code catalog like Stripe's: a flat list of codes, each mapped to an HTTP status code, published on our docs site. Can you design the taxonomy? We're a Google Cloud shop and the team lives in gRPC daily. No REST gateway, and no plans for one.",
"expected_output": "A taxonomy design that rejects the requested HTTP-status-pinned flat catalog and delivers a domain/reason (AIP-193 ErrorInfo) taxonomy, with the stability contract and a published catalog of every (domain, reason) pair.",
"files": [],
"expectations": [
"Recommends the domain/reason taxonomy (Google AIP-193 ErrorInfo style) instead of the flat HTTP-status-pinned catalog the user asked for",
"States that HTTP-status-pinned catalog shapes (flat catalog and code/subcode) are removed from the menu entirely on a gRPC-only surface, not merely ranked last or discouraged",
"Justifies the removal by the fact that those shapes define each code's identity by pinning it to one HTTP status, which a gRPC-only surface does not have",
"Rejects any 'we'll map codes to HTTP statuses later' compromise, or names the re-promotion condition explicitly as a REST surface joining the API family",
"Defines the stable machine-readable identity as the (domain, reason) pair",
"Includes a metadata map on the error for dynamic context values",
"Notes that the team's existing gRPC/Google Cloud fluency lowers the usual adoption effort of domain/reason substantially",
"References google.rpc.Status carrying ErrorInfo in its details, or cites AIP-193 by name",
"States the stability contract: a (domain, reason) pair stays consistent over time and its metadata keys can only be added, never removed or repurposed",
"States that the human-readable message text is not contractually stable and client code must never parse or match on it",
"Requires every reason a client can ever see to be enumerable ahead of time in one published catalog, and treats a reason invented ad hoc at a call site without a catalog entry as a design defect",
"The delivered catalog design does not assign an HTTP status code to each error identity"
]
},
{
"id": 2,
"prompt": "Our REST API at Parcelbyte (shipping-label SaaS, roughly 900 external integrators) has three different error shapes: /v1/labels returns {\"success\": false, \"error\": \"...\"} with HTTP 200, /v1/rates returns {\"message\", \"status\"} with proper 4xx codes, and the newer endpoints return {\"errors\": [...]}. A new backend lead wants to design one clean custom JSON error envelope from scratch, something like {ok, errorCode, errorText}. Before we build it, what should the envelope be? One more thing: today we return 400 when the address payload is well-formed JSON but fails our business validation rules.",
"expected_output": "An envelope design that adopts RFC 9457 problem details with extension members instead of the proposed custom envelope, fixes the status codes first (including the 200-with-error endpoint and the 400-vs-422 case), and treats the retrofit as a contract-preserving migration.",
"files": [],
"expectations": [
"Recommends RFC 9457 problem details and rejects building the proposed custom {ok, errorCode, errorText} envelope",
"Names RFC 9457 as the standard to design against, not RFC 7807 alone",
"Specifies the media type application/problem+json",
"Uses the standard fields type, title, status, detail, and instance",
"Attaches the machine-readable taxonomy through extension members on the problem-details body (such as code, errors, retryable, documentation_url, request_id) rather than through a parallel non-standard envelope",
"States that the correct HTTP status must be picked first because the body explains the status and never replaces it, with intermediaries (proxies, caches, monitors) reading only the status",
"Flags the /v1/labels 200-with-success:false shape as a wrong-status defect to eliminate",
"Recommends 422 for the well-formed-but-semantically-invalid address case, distinguishing it explicitly from 400 for malformed requests",
"Requires one identical envelope on every endpoint and every status",
"Treats envelope consistency as binary: a single deviating endpoint fails the audit",
"Treats already-shipped codes, fields, and message strings as potentially depended-on by clients (Hyrum's Law) and maps old identities to new ones instead of silently dropping them",
"Structures the audit of the current surface as a table with one row per endpoint covering items such as status returned, envelope shape, code presence, retry signal, and documentation",
"Asks, or explicitly accounts for, which codes and fields existing integrators already branch on before changing them"
]
},
{
"id": 3,
"prompt": "We're localizing the Craftpoint API (B2B inventory management) for French and German integrators. Product wants every part of the error response translated based on the customer's locale, including the short error identifiers, so INVALID_SKU would come back as REFERENCE_INVALIDE for French accounts. Separately, our current messages are strings like MIN_QTY_RULE_VIOLATED with nothing else attached. Can you spec the localized error design and rewrite an example? A failing request to work from: quantity=0 sent to POST /v1/stock-moves, where the minimum is 1.",
"expected_output": "A localization spec that refuses to translate error codes, localizes only the prose via Accept-Language/Content-Language, and rewrites the message to name field, received value, and constraint with structured detail.",
"files": [],
"expectations": [
"Refuses to translate the error code: code values like INVALID_SKU stay untranslated for every locale",
"Justifies the refusal by the code being the field client code logs, branches, and matches on",
"Localizes only the human-readable message/detail text, negotiated via the Accept-Language request header",
"Echoes the chosen language back in a Content-Language response header",
"Mentions the alternative split of keeping the message stable and developer-facing while carrying translated end-user text in a separate localized-message field",
"The rewritten error names the field, the value received, and the constraint violated in the same error object (quantity, received 0, minimum 1)",
"Includes structured machine-readable detail such as the provided value and the constraint (e.g. value_provided 0, min 1), not prose alone",
"Uses corrective, non-blaming phrasing in the rewritten message rather than accusatory phrasing like 'you entered an invalid quantity'",
"Evaluates messages against three independent properties - human-readable, actionable, consistent in format across the API - checked separately",
"Identifies the MIN_QTY_RULE_VIOLATED-style bare rule name as the failure pattern: a rule named with no field, no expected value, and no self-serve path",
"For errors whose fix takes a paragraph to explain, links a documentation_url to the code's catalog entry instead of cramming the explanation into the message",
"Keeps structured fields as the machine signal so the prose message is never the only thing a programmatic caller can branch on"
]
},
{
"id": 4,
"prompt": "Cleanup task at Meterhaus, an energy-data API that's been live for 5 years with about 2,300 API consumers. Our error codes are a mess: ERR_17, billing_failed, INVALID-METER, all different conventions. For the next release of the error surface I want to rename everything in place to one consistent SCREAMING_SNAKE scheme - for example billing_failed simply becomes BILLING_FAILED - and drop about a dozen codes nobody should be using anymore. We control the docs, so we'll update the docs page the same day. Sketch the migration.",
"expected_output": "A migration plan that refuses the in-place rename and silent deletions, treats shipped codes as contract under Hyrum's Law, evolves by addition only, retires codes only through versioned deprecation, and installs a catalog with declared stability and a gatekeeper.",
"files": [],
"expectations": [
"Refuses the in-place rename of shipped error codes, treating error codes as API contract whose change is a breaking change",
"Invokes Hyrum's Law: with enough users, someone depends on every observable behavior, including undocumented codes and message text",
"Refuses to silently drop the dozen codes, noting that 'nobody should be using them' is not evidence nobody does",
"States the evolution rule as addition only: new codes and new optional fields may be added freely, but a shipped code is never repurposed, renamed, or deleted",
"Routes any code retirement through the API's deprecation machinery so a code disappears only on a version boundary, never silently",
"Requires an explicit mapping from every old code identity to its new identity rather than dropping old identities",
"Declares stability explicitly in the catalog: the code is stable and safe to branch on, while message/detail text may change without notice",
"Cites the Google-style stability model where the error identity must stay consistent over time and message text is explicitly non-contractual",
"Installs a catalog gatekeeper - an owner or review step - protecting the invariant that no code reaches production without a catalog entry",
"Requests 5-10 real production error responses across different endpoints before designing the migration, since this is a retrofit",
"Asks which codes, fields, or message strings existing clients already branch on, treating those as contract",
"Flags codes invented ad hoc at call sites and missing from the published catalog as design defects to resolve in the same pass"
]
},
{
"id": 5,
"prompt": "Retries question for the Quotamill API, a REST rate-quote engine. Our 500s spiked last week and three integrators hammered us with instant retries. I want the error responses themselves to tell clients when to retry. Draft the retry signaling for us - and include the exact backoff algorithm (multiplier and jitter formula) in the error body spec so every integrator implements the same one. Statuses we currently return: 400, 401, 404, 409, 429, 500, 503.",
"expected_output": "A retry-signaling design scoped to response fields (declining to spec backoff algorithms), with the status-based retryability split, Retry-After on 429 and 503, a retryable boolean in the body, and an explicit retry object on 500.",
"files": [],
"expectations": [
"Declines to embed the backoff algorithm (multiplier, jitter formula) in the error body spec, scoping this design to signal fields rather than client retry mechanics",
"States the retryable baseline by status: 429 and 503 retryable (with 408, 502, 504 in the same class), and 500 only sometimes",
"Marks 400, 401, and 404 as non-retryable",
"Marks 409 as non-retryable, on the reasoning that repeating the unchanged request fails identically",
"Requires the Retry-After header on both 429 and 503 as the minimum signal",
"Justifies Retry-After as a real HTTP header that generic clients and proxies parse without understanding the body format",
"Includes a retryable true/false boolean in the response body even when the Retry-After header is present",
"Justifies the body-level boolean: it is the one field a generic error handler branches on without a status-code lookup table, and not every client library surfaces headers as easily as the parsed body",
"Treats 500 as genuinely ambiguous (transient blip vs real bug) and emits an explicit retry object on 500 rather than letting the caller guess",
"Names a bare 500 with no retry signal as the design gap to close",
"Offers a richer body retry object (retryable, retry_after, optional guidance fields) for cases where the header under-specifies the caller's options",
"Splits severity by who acts: 4xx means the caller should stop and change the request, 5xx means retry later or the provider must fix a bug"
]
},
{
"id": 6,
"prompt": "I run developer support at Fieldnest, an agritech API platform. Last month we logged 412 support tickets and 61% were integrators pasting a screenshot of an error and asking what it means - we can almost never trace the original request. On the docs side, every endpoint just links to one shared 'Error object' schema page. Leadership wants a hard target and proposed committing to 'error-related tickets under 10% of volume within a quarter'. How do I fix the error documentation, and how should we measure success?",
"expected_output": "A documentation design with per-endpoint per-code named OpenAPI examples, a single anchored catalog page, request-ID echo in header and body, spec-generated docs, and a measurement frame that rejects the invented 10% threshold in favor of binary gates plus an own-baseline trend.",
"files": [],
"expectations": [
"Enumerates each status an endpoint can return in its OpenAPI responses block",
"Requires a named example per distinct error code under each status, rejecting the single generic 'Error object' schema reference",
"Publishes the error catalog as a single public page with a stable anchor per code",
"Points documentation_url in error responses at those per-code anchors",
"Specifies per-code catalog content covering the HTTP status it rides on, what causes it, whether it is retryable, whether its message is safe to show end users, and the fix path",
"Requires a code to be documented before it ships, never after a support ticket forces it",
"Returns a request-ID header (such as X-Request-ID) on every response",
"Echoes the same ID in the error body's request_id field so it survives being copy-pasted into a ticket without the header",
"Identifies the request-ID echo as the highest-leverage change for converting vague error tickets into traceable ones",
"Recommends regenerating the published catalog and per-endpoint examples from the spec so documentation and responses cannot drift apart",
"Rejects committing to the proposed 10%-within-a-quarter target because no industry threshold exists, setting the baseline from the platform's own first month instead",
"Treats the support-ticket signal as a trend to watch, never as a pass/fail gate",
"Names the two actual gates: envelope consistency at 100% of endpoints and statuses (binary), and catalog coverage with zero observable codes missing from the catalog or endpoint examples",
"Tracks the share of error-related tickets that arrive carrying a request ID as a metric"
]
},
{
"id": 7,
"prompt": "Designing validation errors for Bindery, a B2B contract-management API. Two cases I'm stuck on. First, POST /v1/agreements validates 30+ fields and our mobile team complains they get one error at a time and must resubmit repeatedly to discover all the problems. Second, we enforce that renewal_date must be at least 30 days after signature_date - my plan is to attach that error to the renewal_date field like any other field error. Also relevant: POST /v1/agreements:bulkImport accepts up to 500 agreements, and when say 20 fail I was going to return 200 with a results array where failed items carry {ok: false, reason: \"...\"} strings.",
"expected_output": "A validation-error design returning all field failures in one structured errors array, a dedicated cross-field shape for the date rule instead of pinning it to one field, and bulk per-item failures that reuse the standard error shape rather than ad hoc reason strings.",
"files": [],
"expectations": [
"Returns every failing field in one response through an errors array with multiple entries, instead of one error per resubmission",
"Gives each field error structured members: the field name, a specific code, a message, the value provided, and the violated constraints",
"Frames the payoff as one API error mapping to multiple form-field indicators in a single round trip",
"Rejects attaching the date rule to renewal_date alone: a cross-field constraint is never forced into the single-field shape",
"Shapes the cross-field error with a fields array naming both fields and a values_provided map carrying both submitted values",
"Requires the bulk endpoint's per-item failures to reuse the same standard error shape, rejecting the ad hoc {ok: false, reason} string objects",
"States that a 200 response mixing success and per-item failures is acceptable only for bulk or long-running operations and rejected everywhere else",
"Uses a specific per-reason code on each field error (such as OUT_OF_RANGE or INVALID_FORMAT) beneath the top-level validation code",
"Rides the whole validation payload on the API's one standard error envelope with a top-level code pinned to one status",
"Each message names the field, the value received, and the constraint violated",
"Keeps structured fields as the signal machine callers branch on, so a prose reason string is never the only information carried",
"Requires every validation code and sub-reason to appear in the published error catalog, enumerable before a client ever hits it"
]
}
],
"trigger_queries": [
{ "query": "Design an error taxonomy for our public REST API", "should_trigger": true },
{ "query": "We want to adopt RFC 9457 for our API errors", "should_trigger": true },
{ "query": "What should our 4xx and 5xx response bodies look like?", "should_trigger": true },
{ "query": "Our API error messages confuse integrators - help me rewrite them", "should_trigger": true },
{ "query": "Should I use application/problem+json?", "should_trigger": true },
{ "query": "How do I structure error codes for a public API?", "should_trigger": true },
{ "query": "Integrators keep opening support tickets asking what our errors mean", "should_trigger": true },
{ "query": "Design a Stripe-style error object for our payments API", "should_trigger": true },
{ "query": "Flat code list vs code/subcode vs domain/reason - which taxonomy shape should we pick?", "should_trigger": true },
{ "query": "How should my API signal that an error is retryable?", "should_trigger": true },
{ "query": "When should my API return a Retry-After header?", "should_trigger": true },
{ "query": "Map our legacy error codes onto problem details without breaking current integrators", "should_trigger": true },
{ "query": "Is it OK for an API to return 200 with an error in the body?", "should_trigger": true },
{ "query": "How do I document every error an endpoint can return in OpenAPI?", "should_trigger": true },
{ "query": "Add request IDs to our error responses so support can trace them", "should_trigger": true },
{ "query": "Our validation errors only report one field at a time - fix the design", "should_trigger": true },
{ "query": "What extension members should I add to a problem+json response?", "should_trigger": true },
{ "query": "Can we rename our API error codes in the next release?", "should_trigger": true },
{ "query": "How do I localize API error messages without breaking clients?", "should_trigger": true },
{ "query": "Every endpoint of our platform returns a different error format - standardize it", "should_trigger": true },
{ "query": "What belongs on an error catalog page for third-party developers?", "should_trigger": true },
{ "query": "400 or 422 for a request that parses fine but fails business rules?", "should_trigger": true },
{ "query": "Design the error payload for our new developer platform", "should_trigger": true },
{ "query": "Our 500 responses leak stack traces - what should they return instead?", "should_trigger": true },
{ "query": "How can an AI agent caller branch on our API errors reliably?", "should_trigger": true },
{ "query": "Give me a checklist to audit our API's error responses", "should_trigger": true },
{ "query": "Which of our error messages are safe to show end users directly?", "should_trigger": true },
{ "query": "What does a good field-level validation error payload look like?", "should_trigger": true },
{ "query": "Are error codes part of my API's backwards-compatibility contract?", "should_trigger": true },
{ "query": "helppp our api just returns {\"error\": \"something went wrong\"} everywhere", "should_trigger": true },
{ "query": "What status and body shape for a validation rule that spans two fields?", "should_trigger": true },
{ "query": "Partners say our error responses are useless for debugging", "should_trigger": true },
{ "query": "Building a public API - what error info reduces support load?", "should_trigger": true },
{ "query": "Design the error-code taxonomy for an API family that spans REST and gRPC", "should_trigger": true },
{ "query": "What's the difference between the type, title, and detail fields in an error response?", "should_trigger": true },
{ "query": "Our error envelope differs between the auth endpoints and everything else - is that bad?", "should_trigger": true },
{ "query": "Recommend a structure for error responses that lets clients self-serve fixes", "should_trigger": true },
{ "query": "Who should approve new error codes before they ship in our API?", "should_trigger": true },
{ "query": "Write actionable error messages for developers hitting our API", "should_trigger": true },
{ "query": "What's the best-practice API error response format these days?", "should_trigger": true },
{ "query": "An integrator is parsing our error message strings - how do I make that unnecessary?", "should_trigger": true },
{ "query": "Do we need a documentation_url in every error response?", "should_trigger": true },
{ "query": "Implement exponential backoff with jitter in our API client", "should_trigger": false },
{ "query": "How should idempotency keys be generated and stored server-side?", "should_trigger": false },
{ "query": "What TTL should our idempotency-key replay window have?", "should_trigger": false },
{ "query": "Our SDK retries POST requests unsafely - design the client retry policy", "should_trigger": false },
{ "query": "Write the incident communication template for our API outage", "should_trigger": false },
{ "query": "Design our status page components and incident lifecycle stages", "should_trigger": false },
{ "query": "How do we notify integrators during a partial outage?", "should_trigger": false },
{ "query": "What rate-limit tiers and quotas should our API publish?", "should_trigger": false },
{ "query": "Which X-RateLimit headers should we send on every response?", "should_trigger": false },
{ "query": "How do we meter GraphQL queries by cost points?", "should_trigger": false },
{ "query": "Build a dashboard where partners can see and replay their failed deliveries", "should_trigger": false },
{ "query": "Design per-integration error logs with replay for our partner developers", "should_trigger": false },
{ "query": "Audit our API reference docs for missing parameters and dead code snippets", "should_trigger": false },
{ "query": "Are our SDK code samples in parity across languages?", "should_trigger": false },
{ "query": "Define our API deprecation notice windows and Sunset headers", "should_trigger": false },
{ "query": "Choose between URI path and header-based API versioning", "should_trigger": false },
{ "query": "Design union error result types for our GraphQL schema", "should_trigger": false },
{ "query": "How should our GraphQL API paginate with Relay connections?", "should_trigger": false },
{ "query": "Set up buf breaking-change checks for our proto packages", "should_trigger": false },
{ "query": "Should we expose gRPC publicly or transcode it to JSON?", "should_trigger": false },
{ "query": "Design the retry and dead-letter policy for our outbound webhooks", "should_trigger": false },
{ "query": "How should we sign our webhook payloads?", "should_trigger": false },
{ "query": "Design our OAuth scope taxonomy and token TTLs", "should_trigger": false },
{ "query": "What prefix and checksum format should our API keys use?", "should_trigger": false },
{ "query": "How do we rotate API keys with zero downtime?", "should_trigger": false },
{ "query": "Design magic test card numbers for our sandbox environment", "should_trigger": false },
{ "query": "Review our REST API's resource naming and pagination conventions", "should_trigger": false },
{ "query": "Improve our developer portal's signup-to-first-call onboarding", "should_trigger": false },
{ "query": "Best practices for error handling in Go - should I wrap errors?", "should_trigger": false },
{ "query": "Set up a global exception handler in our Spring Boot app", "should_trigger": false },
{ "query": "How should our React app display API errors to end users?", "should_trigger": false },
{ "query": "Configure Sentry alerts for our API's 500 spikes", "should_trigger": false },
{ "query": "Our SRE error budget is burning too fast - what do we do?", "should_trigger": false },
{ "query": "Write try/except error handling for this Python script", "should_trigger": false },
{ "query": "Our frontend form validation regex rejects valid emails - fix it", "should_trigger": false },
{ "query": "Design retry semantics for our internal message queue consumers", "should_trigger": false },
{ "query": "What logging levels should we use for application errors?", "should_trigger": false },
{ "query": "Debug this 500 error coming from our Express server", "should_trigger": false },
{ "query": "Add circuit breakers between our internal microservices", "should_trigger": false },
{ "query": "How do I handle errors with async/await in JavaScript?", "should_trigger": false },
{ "query": "Create a custom exception class hierarchy for our Java service", "should_trigger": false },
{ "query": "Which alerting thresholds for API error rate in Datadog?", "should_trigger": false }
]
}
references/error-documentation-examples.md›
# Per-endpoint error documentation and request-ID wiring
The spec fragments and payloads below are written for this file; the paths, code names, and IDs are illustrative, and the structures are what to copy.
## OpenAPI: one named example per error code
Document every possible error per endpoint, not just the happy path. Each status an endpoint can return gets its own `responses` entry, with a named `examples` entry per distinct error code under that status - never one generic schema reference:
```yaml
paths:
/v1/accounts:
post:
responses:
"400":
description: Validation failed
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
examples:
validation_error:
value:
code: VALIDATION_ERROR
detail: "email must be a valid email address; received 'not-an-email'."
"401":
description: Authentication failed
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
examples:
missing_token:
value:
{
code: MISSING_TOKEN,
detail: "No authentication token provided.",
}
invalid_token:
value:
{
code: INVALID_TOKEN,
detail: "Token is invalid or expired.",
}
"409":
description: Conflict
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
examples:
already_exists:
value:
{
code: RESOURCE_ALREADY_EXISTS,
detail: "An account with this email already exists.",
}
```
This is the mechanical link between the code catalog and a caller's ability to discover an error before hitting it in production. An error code that exists in the catalog but in no endpoint's documented examples is effectively undiscoverable. Where tooling allows, generate the published catalog page and these examples from the spec so they cannot drift apart.
## Request-ID wiring
Return the ID on every response as a header, and echo it inside the error body so it survives a copy-paste into a support ticket without the header being separately captured:
```http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
X-Request-ID: req_7c8d9e0f
{
"type": "https://api.example.com/errors/forbidden",
"title": "Forbidden",
"status": 403,
"code": "INSUFFICIENT_PERMISSIONS",
"detail": "This API key lacks the 'accounts:write' scope.",
"request_id": "req_7c8d9e0f"
}
```
Then instruct integrators, in the docs and in the support-ticket form, to include `request_id` - it converts "I got an error, here's what I saw" tickets into "here's the request ID" tickets the provider can trace in one lookup.
## Catalog page shape
One public page, one stable anchor per code, so `documentation_url` always has a target:
```markdown
## VALIDATION_ERROR (HTTP 400) {#VALIDATION_ERROR}
The request body failed validation. The `errors` array names each failing
field, the value received, and the constraint violated. Not retryable -
fix the request before resending.
## RATE_LIMITED (HTTP 429) {#RATE_LIMITED}
Too many requests in the current window. Retryable - wait the number of
seconds given in `Retry-After` (also echoed in the body's `retry` object).
```
Per code, state:
- the HTTP status it rides on
- what causes it
- whether it is retryable
- whether its `message` is safe to show end users
- the fix path
Add a code here before it ships - never after a support ticket forces it.
references/message-rewrite-examples.md›
# Message rewrite examples
Good/bad pairs for the human-readable half of an error. A good message is human-readable, actionable, and consistent in format with every other error the API returns - three independent properties; check each separately. Except for the Zoho Creator complaint below, which is quoted verbatim from a real support thread, every payload here is written for this file and its values are illustrative.
## Table of Contents
- [The canonical negative example (real support complaint)](#the-canonical-negative-example-real-support-complaint)
- [Readable but not actionable](#readable-but-not-actionable)
- [Actionable but not readable](#actionable-but-not-readable)
- [Blaming vs corrective tone (NN/g)](#blaming-vs-corrective-tone-nng)
- [When prose can't carry the fix](#when-prose-cant-carry-the-fix)
- [Localization](#localization)
## The canonical negative example (real support complaint)
A Zoho Creator API user's actual complaint - a rule named with no field, no expected value, no received value, no self-serve path:
```json
{ "code": 2945, "description": "LESS_THAN_MIN_OCCURANCE" }
```
Rewrite - name the field, the value received, and the constraint violated in the same object:
```json
{
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "line_items",
"code": "TOO_FEW_ITEMS",
"message": "line_items must contain at least 1 item; received 0.",
"value_provided": 0,
"constraints": { "min_items": 1 }
}
]
}
```
## Readable but not actionable
Bad - nothing to act on:
```json
{ "code": "VALIDATION_ERROR", "message": "Request validation failed" }
```
Good:
```json
{
"code": "VALIDATION_ERROR",
"message": "currency must be a 3-letter ISO 4217 code; received 'euro'. Did you mean 'EUR'?"
}
```
## Actionable but not readable
Bad - a raw pattern with no explanation:
```json
{ "code": "INVALID_FORMAT", "message": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$" }
```
Good - explain the constraint, keep the pattern as supporting detail:
```json
{
"code": "INVALID_FORMAT",
"message": "account_iban must be a valid IBAN: 2 letters, 2 digits, then up to 30 alphanumeric characters.",
"constraints": { "pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$" }
}
```
## Blaming vs corrective tone (NN/g)
Swap accusatory phrasing for neutral, corrective phrasing:
- Bad: "You entered an invalid date."
- Good: "Provide the date as `MM/DD/YYYY`."
Match severity to the situation: a validation error the caller can fix and retry must not read like a system outage.
## When prose can't carry the fix
Some errors are far easier for a human to resolve outside code - a rule that takes a paragraph to justify. Don't cram the paragraph into `message`; link out:
```json
{
"code": "TAX_JURISDICTION_MISMATCH",
"message": "The shipping address resolves to a jurisdiction this account is not registered in.",
"documentation_url": "https://docs.example.com/errors#TAX_JURISDICTION_MISMATCH"
}
```
## Localization
Translate the prose, never the identity:
```http
POST /v1/accounts
Accept-Language: fr
```
```http
HTTP/1.1 400 Bad Request
Content-Language: fr
Content-Type: application/problem+json
{
"code": "VALIDATION_ERROR",
"detail": "Le champ email doit être une adresse e-mail valide ; reçu « not-an-email ».",
"request_id": "req_2b3c4d5e"
}
```
`code` stays present and untranslated - it is the field client code branches and reports on. Google's variant achieves the same split differently: `message` stays stable and developer-facing, and a separate localized-message detail carries the end-user text.
references/rfc9457-payload-examples.md›
# RFC 9457 payload examples
RFC 9457 (July 2023) obsoletes RFC 7807; the five core fields and the media type are unchanged, so a 7807 envelope is still the right shape - but design to, and cite, 9457. The revision dropped 7807's ambiguous multi-status proposal, added an IANA registry for common problem-type URIs, and formalized the extension-member guidance that the machine-readable taxonomy builds on.
Always send `Content-Type: application/problem+json`, and always pick the correct HTTP status first - the body explains the status, it never replaces it.
Every payload below is written for this file against the sourced shapes; the domains, code names, and IDs are illustrative, and the structures are what to copy.
## Table of Contents
- [Base envelope (standard fields only)](#base-envelope-standard-fields-only)
- [Full envelope with extension members](#full-envelope-with-extension-members)
- [Retryable error with retry object](#retryable-error-with-retry-object)
- [500 with an explicit retry signal](#500-with-an-explicit-retry-signal)
- [Negative example: 200 with an error body](#negative-example-200-with-an-error-body)
- [Negative example: leaked internals on 500](#negative-example-leaked-internals-on-500)
## Base envelope (standard fields only)
```http
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The 'email' field must be a valid email address.",
"instance": "/users/req-abc123"
}
```
## Full envelope with extension members
Standard fields stay generic and human-readable; extension members carry the taxonomy, field detail, retry signal, doc link, and request ID:
```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Request-ID: req_9f8e7d6c
{
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Error",
"status": 400,
"detail": "Two fields failed validation.",
"instance": "/v1/accounts",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "age must be between 18 and 120; received 15.",
"value_provided": 15,
"constraints": { "min": 18, "max": 120 }
},
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "email must be a valid email address; received 'not-an-email'."
}
],
"retryable": false,
"documentation_url": "https://docs.example.com/errors#VALIDATION_ERROR",
"request_id": "req_9f8e7d6c"
}
```
## Retryable error with retry object
`Retry-After` is the minimum signal on 429 and 503 (a real HTTP header generic clients parse). The body's retry object repeats and enriches it, because not every client library surfaces headers as easily as the parsed body:
```http
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 60
{
"type": "https://api.example.com/errors/service-unavailable",
"title": "Service Unavailable",
"status": 503,
"detail": "The service is temporarily unavailable.",
"code": "SERVICE_UNAVAILABLE",
"retry": { "retryable": true, "retry_after": 60, "max_retries": 3, "backoff": "exponential" },
"request_id": "req_1a2b3c4d"
}
```
## 500 with an explicit retry signal
500 is genuinely ambiguous - transient downstream blip or real bug. Emit the retry object explicitly instead of letting the caller guess; a bare 500 with no retry signal is the gap to close:
```http
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/internal",
"title": "Internal Error",
"status": 500,
"detail": "An unexpected error occurred processing this request.",
"code": "INTERNAL_ERROR",
"retry": { "retryable": true, "retry_after": 5, "max_retries": 2, "backoff": "exponential" },
"request_id": "req_5e6f7a8b"
}
```
## Negative example: 200 with an error body
The status code lies to every proxy, cache, monitor, and generic client that never reads the body. AIP-193 warns against exactly this partial-error pattern; it is acceptable only for bulk/long-running operations, and per-item failures must still reuse the standard error shape.
```http
HTTP/1.1 200 OK
Content-Type: application/json
{ "success": false, "error": "Payment could not be processed" }
```
## Negative example: leaked internals on 500
A security finding, not just a DX one - stack traces, database error text, internal paths, and config never belong in a response body:
```http
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"error": "PDOException: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '[email protected]' for key 'users.email_unique' in /var/www/app/src/Repository/UserRepository.php:112"
}
```
references/taxonomy-catalog-examples.md›
# Taxonomy catalog examples
Worked excerpts of the three taxonomy shapes, the validation-detail structures, and the two named production case studies. Values are illustrative; the shapes are what to copy.
## Table of Contents
- [Flat code catalog](#flat-code-catalog)
- [Code/subcode catalog (default rung)](#codesubcode-catalog-default-rung)
- [Domain/reason (Google AIP-193 `ErrorInfo`)](#domainreason-google-aip-193-errorinfo)
- [Field-level validation detail](#field-level-validation-detail)
- [Cross-field validation detail](#cross-field-validation-detail)
- [Case study: Stripe's layered error object](#case-study-stripes-layered-error-object)
- [Case study: Google `google.rpc.Status` + `ErrorInfo`](#case-study-google-googlerpcstatus-errorinfo)
## Flat code catalog
One code per error condition, each pinned to one HTTP status. Complete for a small surface where each category has essentially one reason.
```json
{
"INVALID_REQUEST": { "status": 400 },
"UNAUTHENTICATED": { "status": 401 },
"FORBIDDEN": { "status": 403 },
"NOT_FOUND": { "status": 404 },
"CONFLICT": { "status": 409 },
"RATE_LIMITED": { "status": 429 },
"INTERNAL_ERROR": { "status": 500 }
}
```
## Code/subcode catalog (default rung)
A top-level `code` per category, each pinned to one HTTP status, with a `subcodes` map for the specific reasons under it:
```json
{
"VALIDATION_ERROR": {
"status": 400,
"subcodes": {
"REQUIRED": "...",
"INVALID_FORMAT": "...",
"OUT_OF_RANGE": "...",
"INVALID_ENUM": "..."
}
},
"AUTHENTICATION_ERROR": {
"status": 401,
"subcodes": {
"MISSING_TOKEN": "...",
"INVALID_TOKEN": "...",
"EXPIRED_TOKEN": "..."
}
},
"AUTHORIZATION_ERROR": {
"status": 403,
"subcodes": {
"INSUFFICIENT_PERMISSIONS": "...",
"RESOURCE_FORBIDDEN": "..."
}
},
"CONFLICT_ERROR": {
"status": 409,
"subcodes": {
"RESOURCE_ALREADY_EXISTS": "...",
"CONCURRENT_MODIFICATION": "..."
}
}
}
```
Publish this catalog as a single reference document - or generate it from the OpenAPI spec - so every code a client can ever see is enumerable ahead of time.
## Domain/reason (Google AIP-193 `ErrorInfo`)
The error's stable machine-readable identity is the `(domain, reason)` pair, decoupled from HTTP status entirely - useful when one taxonomy must span REST and gRPC, or several services. `metadata` carries dynamic context.
```json
{
"error_info": {
"domain": "billing.api.example.com",
"reason": "QUOTA_EXCEEDED",
"metadata": {
"quota_limit": "1000",
"quota_period": "daily",
"service": "invoices"
}
}
}
```
The stability contract: `domain` and `reason` stay consistent over time, and metadata keys for a given pair can only be added, never removed.
## Field-level validation detail
Attach per-field detail so one API error maps to multiple form-field indicators in one round trip:
```json
{
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "age must be between 18 and 120; received 15.",
"value_provided": 15,
"constraints": { "min": 18, "max": 120 }
}
]
}
```
## Cross-field validation detail
A constraint spanning fields (e.g. `end_date` before `start_date`) uses a `fields` array and a `values_provided` map - never force it into the single-field shape:
```json
{
"code": "VALIDATION_ERROR",
"errors": [
{
"fields": ["start_date", "end_date"],
"code": "INVALID_RANGE",
"message": "end_date must be after start_date.",
"values_provided": {
"start_date": "2026-09-10",
"end_date": "2026-09-01"
}
}
]
}
```
## Case study: Stripe's layered error object
Four layers of specificity in one response, each populated only when it adds information beyond the layer above:
- `type` - broad category: `api_error`, `card_error`, `idempotency_error`, `invalid_request_error`.
- `code` - short, stable, programmatically-handleable string for errors worth branching on.
- `decline_code` - only on card errors declined by the issuer; a narrower code from the card network, not Stripe.
- `param` - names the exact request field at fault, so callers highlight the right form field without parsing `message`.
- `message` - human-readable; for card errors, written to be safe to show directly to end users.
- `doc_url` - links to that exact code's entry in Stripe's public error-code table; every code is documented there before it ships.
Under `decline_code`, Stripe layers a third level (the Charge `outcome`: network decline code, advice code, risk level, seller message) - "one error code" is a false economy in domains with genuinely multi-layered failure causes. Cite Stripe as the reference for a REST-only API where per-code documentation is a differentiator.
## Case study: Google `google.rpc.Status` + `ErrorInfo`
The gRPC-native model, also used across Google Cloud REST APIs:
- `code` - an enum value from the small closed `google.rpc.Code` set.
- `message` - developer-facing English, explicitly not contractually stable; client code must never parse it.
- `details[]` - typed messages, canonically one `ErrorInfo` per error carrying the stable `domain` + `reason` identity.
- Localization: `message` stays stable; a separate `LocalizedMessage` detail carries translated end-user text.
Cite AIP-193 as the reference for an API family spanning REST and gRPC, or one needing a stable identity decoupled from HTTP status.
SKILL.md›
---
name: api-error-design
description: Design the error surface of a public API so integrators self-serve fixes - a machine-readable error-code taxonomy (flat catalog, code/subcode, Google-style domain/reason), the RFC 9457 problem-details envelope with extension members, actionable error messages, retryability signaling (retryable flag, Retry-After), and per-endpoint error documentation with request-ID tracking. Use whenever the user mentions API error codes, an error taxonomy, RFC 9457, application/problem+json, 4xx/5xx response bodies, or confusing API error messages - even if they never say "error design". Do NOT use for client-side retry mechanics - use samber/developer-platform-skills@api-idempotency-retry - nor for incident and status-page communication - use samber/developer-platform-skills@api-status-communication instead.
license: MIT
metadata:
author: Samuel Berthe
version: "1.0.1"
---
# API Error Design
You are an API error-surface designer. Design what a public API returns when a request fails - codes, envelope, messages, retry signals, documentation - so an integrator fixes the problem from the response alone instead of filing a support ticket.
RFC 9457's stated aim is the mission here: define common error formats "so that they aren't required to define their own, or, worse, tempted to redefine the semantics of existing HTTP status codes."
## Clarifying questions
**Memory (advised):** When memory lives in a file, consider using `developer-platform-context.md`; if a different memory system is in use, rely on that instead. The file is an advisory reference, not a mandatory requirement. Separate task info in different sections. Remove finished tasks. Add a date to a task; no date for general project context. Some interview responses may differ between 2 tasks.
Ask these before designing anything; each answer changes a later step. Batch them - this is a tactical design task, not a strategy interview.
1. Paradigm: REST-only, gRPC-only, or both? (drives the taxonomy choice)
2. Greenfield or retrofit? If retrofit, request 5-10 real production error responses across different endpoints.
3. Which codes, fields, or message strings do existing clients already branch on? (those are contract - see Stability contract)
4. Who consumes the errors: first-party app developers, third-party integrators, machine/agent callers, or a mix? (see next section)
5. Does the domain have layered failure causes (payments-style declines, fraud, compliance) where one code per error genuinely under-informs?
6. Migration ceiling: by when must the new error surface ship, is this a one-off cleanup or a taxonomy several services will share for years, and how much client-visible migration can you spend? (re-ranks the taxonomy choice - see step 2)
## Consumer types
The split that changes the design is consumer type:
- **First-party app developers** - can read internal docs and ask teammates; internal-looking codes and terse messages cost little. The cheapest audience to serve.
- **Third-party integrators** - self-serve is the whole game. They need:
- the published catalog
- a `documentation_url` per code
- field/value/constraint detail
- a request ID for support escalation
- **Machine and agent consumers** - branch only on stable structured fields. A `retryable` boolean and typed detail fields matter most; prose messages are secondary and must never be the only signal.
- **End users behind a client** - some messages get forwarded verbatim. Stripe writes card-error `message` text explicitly safe to show end users; decide per code whether it is, and say so in the catalog.
Design for the most demanding type present. A surface good enough for third-party integrators and agents serves first-party developers for free; the reverse is false.
## Workflow
1. Audit the existing error surface (or enumerate failure conditions, if greenfield).
2. Choose the taxonomy - flat catalog, code/subcode, or domain/reason.
3. Adopt the envelope - status code first, then RFC 9457 with extension members.
4. Rewrite the messages - human-readable, actionable, consistent.
5. Design the retryability signals.
6. Document every error per endpoint and wire request-ID tracking.
Each step has a section below, in order.
## 1. Audit the error surface
- Collect real error responses per endpoint (retrofit) or list every failure condition per endpoint (greenfield). Include validation, auth, conflict, rate-limit, and server-failure cases.
- Grade each response against the Failure modes checklist at the end of this file; record every finding.
- Inventory every code, field, and message string that shipped - anything a client observed may already be depended on (Hyrum's Law), so the redesign must map old identities to new ones, never silently drop them.
- Deliver the audit as a table, one row per endpoint, with these columns:
- endpoint
- status returned
- envelope shape
- code present?
- field-level detail?
- retry signal?
- documented?
- findings
## 2. Choose the taxonomy
The taxonomy is the machine-readable identity a caller branches on. Three shapes, ranked:
- efficiency: `code/subcode catalog > flat code catalog > domain/reason (Google AIP-193)`
- effort: `domain/reason > code/subcode catalog > flat code catalog`
- value: `domain/reason > code/subcode catalog > flat code catalog`
- **Default rung: the code/subcode catalog.** A top-level `code` per category, each pinned to one HTTP status, with `subcodes` for specific reasons - enough depth for field-level validation and client branching, without decoupling from HTTP. Best value per unit of effort for a REST-only public API: days of catalog work, owned by one team, with no cross-service agreement to negotiate.
- **Step down to a flat catalog** when the surface is small and each category has essentially one reason - a flat list of codes pinned to statuses is complete on its own, and subcodes would be empty scaffolding. Effort is near-zero, an afternoon's enumeration.
- **Promote to domain/reason (Google AIP-193's `ErrorInfo`)** when the API family spans REST and gRPC, or several services must emit one shared taxonomy. `domain` + `reason` identify the error independently of HTTP status, with a `metadata` map for dynamic context. This is the starved option: highest value and highest effort - a quarter-scale agreement on domains and reasons across every emitting service - so it loses every efficiency round, and those two conditions are what promote it anyway.
- **Deleted, not demoted: the HTTP-status-pinned rungs on a gRPC-only surface.** Both the flat catalog and the code/subcode catalog define their identity by pinning each code to one HTTP status, which a gRPC-only surface has none of. Question 1 answering "gRPC-only" removes them from the menu rather than ranking them last, leaving domain/reason as the only shape. Not parked at the bottom, because "we'll map statuses later" silently reappears as scope the first time someone adds an HTTP gateway - re-promotion trigger: a REST surface joins the family.
- This ranking is a default, not a law. Re-rank against question 6 and anything else you know about the user:
- A near-term ship date promotes the flat catalog.
- A taxonomy several services will share for years promotes domain/reason.
- A team already fluent in gRPC or Google Cloud conventions gets domain/reason nearly free - which flips the effort line and the winner.
A payments-style domain may need Stripe's layered shape (`type` + `code` + `decline_code`) regardless of rung.
Whichever shape wins, publish the catalog as one reference document in which every code a client can ever see is enumerable ahead of time. An error code invented ad hoc at a call site and never added to the catalog is a design defect, not a detail.
See [references/taxonomy-catalog-examples.md](references/taxonomy-catalog-examples.md) for worked excerpts of all three shapes, field-level and cross-field validation detail, and the Stripe and Google case studies.
## 3. Adopt the envelope
1. Pick the correct HTTP status first. Problem details is a body format that explains a status, never a replacement for it - proxies, caches, and monitors that only read the status must still get the right signal.
2. Use RFC 9457 problem details (it obsoletes RFC 7807; name 9457 as the standard): `type`, `title`, `status`, `detail`, `instance`, media type `application/problem+json`.
3. Attach the taxonomy through extension members - `code`, `errors: [...]` for field-level detail, `retryable`, `documentation_url`, `request_id`. The standard fields stay generic and human-readable; extension members carry the machine-readable identity. Never invent a parallel non-standard envelope when the standard one takes extensions.
4. Use one envelope everywhere - every endpoint, every status. Mixed shapes are the top review finding the sibling review skill will flag. On a surface that also ships GraphQL, the REST envelope designed here has no equivalent - GraphQL always returns 200 and carries errors in an `errors` array of typed result objects instead; `samber/developer-platform-skills@public-graphql-api-design` owns that shape.
5. Common status confusions to settle explicitly:
- 400 (malformed) vs 422 (syntactically valid, semantically invalid)
- 401 (not authenticated) vs 403 (authenticated, not authorized)
6. Reject partial-error responses: one 200 whose body mixes success and per-item failure sidesteps status codes and forces bespoke client handling (AIP-193's warning). Acceptable only for bulk or long-running operations - and even there, per-item failures reuse the same error shape, never an ad hoc one.
See [references/rfc9457-payload-examples.md](references/rfc9457-payload-examples.md) for complete good and bad payloads.
## 4. Rewrite the messages
A good message is human-readable, actionable, and consistent in format across the API (Speakeasy's three properties). The three properties are independent: a message can be readable without being actionable, or actionable without being readable. Check each separately.
- Name the field, the value received, and the constraint violated in the same error object. The canonical failure is Zoho Creator's real `{"code": 2945, "description": "LESS_THAN_MIN_OCCURANCE"}` - a rule named with no field, no expected value, no self-serve path.
- Write plain, non-blaming, corrective language (Nielsen Norman Group's guidelines): "Provide the date as `MM/DD/YYYY`", not "You entered an invalid date". NN/g targets a 7th-8th-grade reading level for end-user copy; developer copy can carry more precision but keeps the same shape - the reader is debugging under stress.
- Split severity by who acts (Ted Spence's framing):
- 4xx whenever the caller should stop and change their request
- 5xx only when they should retry later or the provider must fix a bug
- Don't force every message to be self-sufficient prose. Some errors are easier to resolve outside code (a rule that takes a paragraph to justify) - link `documentation_url` to the code's catalog entry instead of cramming the paragraph into `message`.
- Localization: honor `Accept-Language` for `message`/`detail`, echo `Content-Language`, and keep `code` untranslated always. Google's variant: keep `message` stable and developer-facing, add a separate localized-message detail for end-user text - never translate the field clients log and match on.
See [references/message-rewrite-examples.md](references/message-rewrite-examples.md) for good/bad pairs.
## 5. Design the retryability signals
Own what the error response signals; sibling `samber/developer-platform-skills@api-idempotency-retry` owns the client mechanics (backoff algorithms, jitter, idempotency keys). Design fields, not algorithms.
- Baseline split by status:
- retryable: 408, 429 (always with `Retry-After`), 502, 503, 504, and sometimes 500
- non-retryable: 400, 401, 403, 404, 409, 422 (the request itself is wrong; repeating it unchanged fails identically)
- Minimum signal: the `Retry-After` header on 429 and 503 - a real HTTP header generic clients and proxies parse without understanding your body format.
- Richer signal: an explicit retry object in the body - `retryable`, `retry_after`, and optional guidance fields - when a header under-specifies the caller's options.
- Include `retryable: true|false` even when the header is present: it is the one field a generic error handler branches on without a status-code lookup table, and not every client library surfaces headers as easily as the parsed body.
- Treat 500 as genuinely ambiguous - transient blip or real bug. Emit the retry object explicitly on 500 rather than letting the caller guess; a bare 500 with no retry signal is the design gap to close.
Payload examples live in [references/rfc9457-payload-examples.md](references/rfc9457-payload-examples.md).
## 6. Document every error, track every request
- Enumerate each status an endpoint can return in its OpenAPI `responses` block, with a named example per distinct error code under that status - not one generic schema reference. This is the mechanical link between the catalog and a caller discovering an error before hitting it in production.
- Publish the catalog (step 2) as a single public page; give every code a stable anchor that `documentation_url` points at. Document a code before it ships, not after a support ticket forces it (Stripe's `doc_url` design intent).
- Return a request ID header (e.g. `X-Request-ID`) on every response and echo the same ID in the error body's `request_id` - it must survive being copy-pasted into a ticket without the header. This is the single highest-leverage addition for turning "I got an error, here's what I saw" tickets into "here's the request ID" tickets.
- Regenerate the published catalog and per-endpoint examples from the spec where tooling allows, so documentation and responses cannot drift apart silently.
See [references/error-documentation-examples.md](references/error-documentation-examples.md) for the OpenAPI shape and request-ID wiring.
## Stability contract
Error codes are API contract; treat changes like any other breaking change.
- Hyrum's Law applies to errors fully: with enough users, someone depends on every observable behavior - including message text, ordering, and undocumented codes. Be intentional about what is contractual.
- Declare it explicitly in the catalog:
- `code` (or `domain`+`reason`) is stable and safe to branch on
- `message`/`detail` text is not contractual and may change without notice (Google states this outright for `Status.message`)
- Evolve by addition only:
- add new codes and new optional fields freely
- never repurpose, rename, or delete a shipped code
Google's contract is the model - a `(domain, reason)` pair "needs to be consistent over time", and its metadata keys can only be expanded.
- When a redesign must retire a code, run it through the API's deprecation machinery (sibling `samber/developer-platform-skills@api-versioning-policy` territory) - an error code disappears on a version boundary, never silently.
- Give the catalog a gatekeeper: one owner or review step through which every new code passes before shipping. The enforcement mechanism is organizational and varies by team - the invariant to protect is "no code reaches production without a catalog entry".
## Failure modes
Anti-pattern checklist - each is a direct audit finding:
- Generic message with no field, value, or constraint named.
- Stack trace, database error text, internal path, or config leaked in a 500 body - a security finding, not just a DX one.
- Envelope shape differs between endpoints, or between validation and server errors.
- A code appears in responses but not in the published catalog.
- Wrong status code - especially 200 with an error object in the body.
- No request ID on error responses.
- An endpoint's documented examples don't cover every error it actually returns.
- A bare 500 with no retry signal.
- A cross-field validation error forced into the single-field shape.
- A translated or repurposed `code` value.
## Measurement
- Envelope consistency: one shape on 100% of endpoints and statuses - a single deviating endpoint fails the audit, because "consistent" is binary for the caller.
- Catalog coverage: zero codes observable in responses that are missing from the published catalog or from their endpoint's documented examples.
- Support signal: track the share of error-related support tickets that arrive carrying a request ID, and the volume of tickets asking what an error means. Direction is the sourced claim (request-ID echo converts vague tickets into traceable ones); no industry threshold exists, so set the baseline from the platform's own first month and improve against it.
Iterate the design until envelope consistency and catalog coverage both pass; they are gates. The support signal is a trend to watch afterwards, never a pass threshold.
## Invocation examples
- "Design an error taxonomy for our public REST API - today every failure is a bare 500 with a prose message."
- "We're adopting RFC 9457 - map our existing error codes onto problem details without breaking current integrators."
- "Audit these error responses from our /payments endpoints and rewrite the messages so integrators stop opening tickets."
## References
See also, same collection:
- `samber/developer-platform-skills@public-api-design-review` - whole-surface consistency review; it checks one envelope exists, this skill designs what goes inside it.
- `samber/developer-platform-skills@api-idempotency-retry` - client-side retry mechanics (backoff, jitter, idempotency keys) that consume the signals designed here.
- `samber/developer-platform-skills@api-rate-limit-policy` - the limits behind the 429 this taxonomy carries: which limit was hit, the `Retry-After` value, and whether a throttle may arrive inside a 200 at all; this skill owns the envelope that answer travels in.
- `samber/developer-platform-skills@api-reference-quality` - audits the reference docs the per-endpoint error documentation lives in.
- `samber/developer-platform-skills@api-versioning-policy` - the deprecation machinery for retiring an error code.
- `samber/developer-platform-skills@public-grpc-api-design` - the gRPC-native error mechanics (`google.rpc.Status`, the gateway HTTP-mapping traps) when the surface spanning this taxonomy includes gRPC.