Errors & Validation
An error is part of the contract, not an afterthought. This chapter is a machine-readable error model (RFC 9457 problem details), field-level validation, choosing the status code that matches what happened, and partial failure in batch requests.
The error is the contract
Callers write code against your errors as surely as they write code against your success bodies. A client that creates a short link has an if branch for "the custom code is taken" and another for "the token expired". Each branch reads something your server sent. If what it reads is a sentence like "Oops, that code is already in use!", you have promised that sentence forever. The day someone fixes the typo, a client breaks.
So an error response needs three things a bare status or a prose blob cannot give: a stable identity the client can branch on, a human explanation the client can show or log, and enough structure to point at the exact field that failed. Everything in this chapter follows from that. Snip's API (https://api.snip.ly/v1, the control plane; the redirect path is the data plane and has its own, much smaller, error surface) uses one error model on every endpoint. Define it once, here, and every other chapter points back to it.
Problem details: the shape
You do not need to invent the model. RFC 9457 (which replaced RFC 7807) defines problem details: a JSON object sent with the media type application/problem+json. It has five standard members.
| Member | Meaning | For whom |
|---|---|---|
type | A URI that names the kind of problem. The error's identity. | Machines |
title | A short summary of the kind of problem. Same for every instance of that type. | Humans |
status | The HTTP status, repeated in the body so it survives proxies and logs. | Both |
detail | What went wrong in this request. | Humans |
instance | A URI for this occurrence. Snip uses urn:snip:request:<request-id>. | Support, logs |
The key member is type. It is a URI such as https://api.snip.ly/errors/invalid-url, and it should resolve to a page that documents the error, its causes and its fix. Treat it as an opaque string for comparison: the client matches it exactly and never parses it. Because it is a URI under your own domain, you can mint new ones without a central registry and without collisions.
RFC 9457 lets you add extension members, and Snip adds two. errors is an array of field-level problems, each with a field (a JSON Pointer such as /tags/1), a stable code and a human message. request_id repeats the id from instance as a plain string, because support staff paste it into a search box and should not have to strip a prefix. Clients must ignore members they do not recognise; that rule lets you add more later without a new version.
Use the standard, even for a private API. Every serious HTTP client library and gateway already knows application/problem+json. A custom {"error": {"msg": …}} envelope costs you the same design effort and gives you none of that tooling.
A bad POST /v1/links, end to end
A caller posts a new link with no url and a tag that contains a space.
POST /v1/links HTTP/1.1
Host: api.snip.ly
Authorization: Bearer sk_live_9f3a…
Content-Type: application/json
Idempotency-Key: 6c1e2b0a-51d4-4a7e-9b1d-0f2a7c3e8d11
{"code": "launch", "tags": ["promo", "spring sale"]}
The JSON parses, so this is not a 400. The content breaks the rules, so Snip answers 422, and it reports both problems:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://api.snip.ly/errors/validation-failed",
"title": "Request validation failed",
"status": 422,
"detail": "2 fields failed validation. See `errors`.",
"instance": "urn:snip:request:req_8Qm2vK1x",
"request_id": "req_8Qm2vK1x",
"errors": [
{
"field": "/url",
"code": "required",
"message": "url is required."
},
{
"field": "/tags/1",
"code": "invalid_tag",
"message": "Tags may contain letters, digits and hyphens only (got \"spring sale\")."
}
]
}
Two levels of identity are at work. The top-level type says the request failed validation, which tells the client to read errors. Each entry's code says which rule failed, which tells a form to highlight one input. Neither depends on the English text. The message can be rewritten, shortened or localised next month without touching a single client.
A single-cause error uses the same shape without the array. Asking for a code someone owns already:
{
"type": "https://api.snip.ly/errors/code-taken",
"title": "Custom code already in use",
"status": 409,
"detail": "The code \"launch\" belongs to another link.",
"instance": "urn:snip:request:req_3Zt9aP7c",
"request_id": "req_3Zt9aP7c"
}
Branch on type, not on text
The rule for clients is short: branch on type (and on errors[].code); show detail but never parse it. The rule for you is the mirror image: type and code values are permanent, and title, detail and message are free to change.
This makes the set of error types part of your API surface. Adding a new type is an additive change, but only if clients have a sensible default for types they do not know, such as "treat by status class". Renaming or splitting an existing type breaks callers exactly as renaming a response field would. Versioning & Evolution covers how to add and retire these without a new major version. The practical consequence is a review step: when someone adds an error, the pull request names a type URI, documents it, and lists which status it uses.
Keep the status and the type consistent. One type maps to exactly one status. If code-taken sometimes returns 409 and sometimes 400, clients cannot trust either number.
Choosing the status code
The status code is the coarse signal: it is what proxies, SDKs, dashboards and retry logic read before they look at the body. Pick the code that says who is at fault and what to do. Chapter Resource Modeling covers success codes and method semantics; this section covers failures.
400 or 422: pick one rule and state it
RFC 9110 defines 400 as a request the server will not process because of something it sees as a client error, and 422 as a request that is well-formed but cannot be followed because of semantic errors. Teams argue about which one validation failures deserve, and both answers work. What does not work is mixing them. Snip's rule is:
- 400: the server could not parse the request. Broken JSON, a body that is not an object, a wrong type for the whole document, an unknown query parameter syntax.
- 422: the request parsed, but the values break a rule. Missing
url, malformed URL, tag with a space,expires_atin the past.
The advantage of this split is that 400 almost always means a bug in the client's serialisation, and 422 almost always means bad user input. They go to different people. If you prefer one code for both, choose 400 and put the whole distinction in type; say so in the docs. Just choose once for the whole API.
401, 403 and 404
- 401 means "I do not know who you are": no credential, an expired token, a revoked key. Send a
WWW-Authenticateheader. The remedy is to authenticate again. - 403 means "I know who you are, and you may not do this": a valid token without the
links:writescope, or an action your plan does not include. Re-authenticating will not help. Auth & Scopes covers what each scope allows. - 404 means no such resource, and Snip also returns it when the resource exists but belongs to another account. A 403 on
GET /v1/links/abc123tells an attacker thatabc123exists; a 404 tells them nothing. Use this trade whenever existence is itself sensitive. Keep 403 for cases where the caller already knows the resource is there, such as a missing scope on an endpoint they can see.
The rest of the 4xx set
- 409 Conflict: the request is valid but clashes with current state. The custom code is taken; the link was archived and cannot be edited. Caching, Concurrency & Idempotency covers 409 for write races and idempotency-key reuse.
- 412 Precondition Failed: an
If-Matchcheck failed because someone else changed the link first. The caller should re-read and retry; also see chapter 08. - 429 Too Many Requests: over quota, with
Retry-After. See Rate Limits & Quotas. - 405 Method Not Allowed (with an
Allowheader) and 415 Unsupported Media Type (a body that is notapplication/json) are cheap to get right and help people who are debugging with curl.
5xx: our fault
500 is a bug in Snip. The body is a generic problem of type https://api.snip.ly/errors/internal with a request id and nothing else. 503 means Snip is overloaded or in maintenance; it carries Retry-After. Do not use 502 or 504 for your own application errors: those belong to gateways, and clients and monitors treat them as network trouble.
Status code table
| Code | Meaning in Snip | At fault | Retry? |
|---|---|---|---|
| 400 | Cannot parse the request | Client | No; fix the request |
| 401 | No or invalid credential | Client | After re-authenticating |
| 403 | Authenticated, not permitted | Client | No |
| 404 | No such resource (or not yours) | Client | No |
| 405 / 415 | Wrong method / wrong media type | Client | No |
| 409 | Conflicts with current state | Client | Only after the state changes |
| 412 | Precondition (If-Match) failed | Client | After re-reading |
| 422 | Parsed, but a value breaks a rule | Client | No; fix the values |
| 429 | Rate or quota exceeded | Client | Yes, after Retry-After |
| 500 | Server bug | Server | Maybe, with backoff |
| 503 | Overloaded or down | Server | Yes, after Retry-After |
Validation strategy
Validate at the edge, before any work. Check shape, types, lengths, formats and ranges in the request layer, before you open a transaction or call another service. These checks need no state, cost microseconds, and give the clearest messages. State-dependent checks (is the code taken?) come after, and they produce 409, not 422.
Report every problem at once. The example above returned two errors in one response. Failing on the first error forces a caller with five bad fields to make five round trips, and a form UI cannot highlight the rest. The cost is that your validator must collect, not throw. Build it that way from the start; retrofitting is painful.
Be strict on input, consistent on output. Reject unknown fields in a request body with a unknown_field code rather than silently ignoring them. A caller who sends "expire_at" for "expires_at" should learn so immediately, not discover months later that links never expired. Strictness has one cost: you cannot add a field to a request and let old clients send it. That is fine, since old clients do not know the field. On the response side, do the opposite of strict: always the same shape, the same member names, the same errors array even when it holds one item.
Validate meaning, not only syntax. A url that parses as a URL still may not be shortenable: wrong scheme (javascript:), a private IP, or a host on your block list. Those are 422s with their own codes (invalid_scheme, blocked_host), not a generic "invalid".
Do not leak internals
Error messages are an information channel to attackers and a support burden for you. A 500 that returns psycopg2.errors.UniqueViolation: duplicate key value violates constraint "links_code_key" on host db-primary-3.internal tells the world your database, your schema and your host naming. Never return stack traces, SQL, file paths, internal hostnames or library versions.
The pattern is: log the full detail on the server, keyed by a request id; return only the id and a generic problem.
{
"type": "https://api.snip.ly/errors/internal",
"title": "Internal error",
"status": 500,
"detail": "Something went wrong on our side. Quote the request id if you contact support.",
"instance": "urn:snip:request:req_5Wd0nB2e",
"request_id": "req_5Wd0nB2e"
}
Generate the id at the edge, put it in a header (Snip-Request-Id) on every response, success or failure, and attach it to every log line and trace span for that request. Support then goes from the customer's id to the full server-side story in one search. Your own known failures follow the same rule: code-taken may say which code, since the caller sent it, but should not say which account owns it.
Beware echoing input. Reflecting a bad value into message is useful, but cap its length and escape it. A 10 MB "invalid tag" in your error body is a cheap amplification attack, and unescaped input in an error shown in a dashboard is an injection vector.
Partial failure in batch requests
Suppose Snip offers POST /v1/links:batch with an array of up to 100 links. What does a response mean when 97 succeed and 3 fail? There are two honest designs.
All or nothing. Validate everything, write in one transaction, return one status. If any item fails, nothing is written and the response is a single 422 whose errors paths include the index (/items/41/url). This is right when the items depend on each other or the set has one meaning, such as a campaign that must exist as a whole. It needs real transactions, which gets harder as the batch crosses shards.
Per-item results. Each item is processed on its own and the response reports a status for each. This is right when the items are independent, which is the common case for link creation. Return 200 for the request itself (the batch was understood and processed) and put each outcome in the body:
{
"results": [
{ "index": 0, "status": 201, "link": { "code": "a1b2c3", "short_url": "https://snip.ly/a1b2c3" } },
{ "index": 1, "status": 409,
"problem": {
"type": "https://api.snip.ly/errors/code-taken",
"title": "Custom code already in use",
"status": 409,
"detail": "The code \"launch\" belongs to another link."
} },
{ "index": 2, "status": 422,
"problem": {
"type": "https://api.snip.ly/errors/validation-failed",
"title": "Request validation failed",
"status": 422,
"errors": [ { "field": "/url", "code": "required", "message": "url is required." } ]
} }
],
"summary": { "succeeded": 1, "failed": 2 }
}
I recommend per-item results for independent items. Each problem is the same object the single-item endpoint returns, so clients reuse one error handler. The outer status stays honest about the request: 400 or 422 if the envelope itself is bad (not an array, over 100 items), 200 once it was processed. Some APIs use 207 Multi-Status, which comes from WebDAV; plain 200 with a per-item status is simpler and works with every client.
Retrying a partly failed batch is where the design is tested. The caller should resend only the failed items that are retriable, and each item should carry its own idempotency key so a resend cannot create a duplicate. See Caching, Concurrency & Idempotency.
Retriable or not: make it derivable
A client deciding whether to retry has two inputs, the status and the type, and your job is to make the answer a pure function of them. The table above does that. Statuses 400, 401, 403, 404, 405, 415 and 422 are never retried without a change by the caller. 429 and 503 are retried after the time in Retry-After. 500 may be retried a few times. 409 and 412 are retried only after the caller has re-read state.
Where a status is ambiguous, let the type settle it, and document that. For example, https://api.snip.ly/errors/upstream-timeout may share status 503 with an overload but tells the client the write may have happened. That case, and why retrying a POST is only safe with an Idempotency-Key, belongs to chapter 08.
Retries themselves need discipline: exponential backoff with jitter, a cap on attempts, and honouring Retry-After over the client's own schedule. A fleet of clients that retry on the same beat turns a brief overload into a longer outage. Reliability & Degradation explains that failure mode, retry budgets and load shedding in detail. Put the retry rules in your SDK so integrators do not each reinvent them wrongly.
✓ Check yourself
Try each without scrolling up. The answers fold away.
-
A client shows users the
detailstring and also checksdetail.includes("already in use")to decide whether to suggest another code. What breaks, and what should it check?Show answer
The text is not part of the contract, so any rewording or translation silently breaks the branch. It should compare
typetohttps://api.snip.ly/errors/code-taken. See Branch on type, not on text. -
A caller sends valid JSON in which
expires_atis a date in the past. Which status, and why not the other candidate?Show answer
422: the body parsed, but a value breaks a rule. 400 is for requests the server cannot parse. Whichever you choose, apply it to the whole API. See Choosing the status code.
-
A token holder asks for
GET /v1/links/abc123, a link owned by another account. Why might the API return 404 and not 403?Show answer
A 403 confirms the link exists, which lets an attacker enumerate codes or accounts. A 404 gives the same answer for "absent" and "not yours". See Choosing the status code.
-
Your validator stops at the first bad field. Describe what a caller with a form of five fields experiences, and what you change.
Show answer
They fix one field, resubmit, and meet the next error, up to five round trips, and the form cannot mark all bad inputs. Collect every failure into the
errorsarray and return them together. See Validation strategy. -
A batch of 50 links returns 200 with two failed items. How does a client decide what to resend?
Show answer
It reads each failed item's
statusandproblem.type, resends only those that are retriable (for example 503 or 429), and fixes or drops the rest. Per-item idempotency keys stop duplicates on resend. See Partial failure in batches and Retriable or not. -
A production 500 contains a database error message. List two harms and the fix.
Show answer
It exposes schema and host names to attackers, and it ties clients to text you will want to change. Log the detail server-side under a request id and return a generic problem with that id. See Do not leak internals.
Takeaway
- An error body is contract. Use RFC 9457
application/problem+jsonon every endpoint, with one extension,errors[], for field-level problems. - Clients branch on
typeandcode.title,detailandmessageare for people and may change. - Choose one rule for 400 versus 422 and state it. Snip: 400 cannot parse, 422 parsed but invalid. Return 404 where a 403 would reveal that a resource exists.
- Validate at the edge, return every field error at once, reject unknown input fields, and keep the output shape constant.
- Never leak stack traces or SQL. Log the detail with a request id and return the id in the body.
- For independent batch items, return per-item results with the same problem objects. Make retriability a function of status and
type.
Next: how to change all of this later without breaking callers. Versioning & Evolution →
Further reading
- RFC 9457: Problem Details for HTTP APIs — the standard behind the error model, including extension members and the
typerules. - RFC 9110: HTTP Semantics, Status Codes — the authoritative meaning of every code used here.
- MDN: HTTP response status codes — a readable reference with notes on when each code applies.