Skip to content

Errors

Every refusal is a problem document, and its type resolves to a page explaining it.

Every refusal is an RFC 9457 problem document, served as application/problem+json. Negotiate on the media type rather than on the status line: it is the only signal that tells a problem document apart from a successful body that happens to carry a status field.

{
  "type": "https://lateano.com/api/problems/invalid-query",
  "title": "The query parsed but does not describe a question this resource can answer",
  "status": 422,
  "detail": "…what was wrong with this particular request…"
}

That title is the real one, character for character, which matters for the reason the next section gives. The detail varies per request and is elided here rather than invented — a sample string in that slot would be a sentence this API does not produce.

The type URI resolves

RFC 9457 permits type to be an opaque identifier. This API gives that up deliberately: every type is a real address that serves a page describing the problem. Fetch it and you get the status, the title, and what to do about it.

curl -sS https://lateano.com/api/problems/invalid-query

The URI is derived from the catalogue entry rather than written at the place the error is raised, and the page is generated from the same entry. So a type that resolves is a property of how the API is built, not of somebody having remembered to write a page.

Read detail, not title

title is a short summary that does not change between occurrences — it identifies the kind of failure and is safe to switch on. detail is what varies: the field that was wrong, the value that was rejected, the header that was missing. A client that logs only title throws away everything that would let a person fix the request.

Four failures that look like one

The commonest mistake in an API client is collapsing every 4xx to “bad request”. These are distinct because the caller’s next move is distinct.

Status Type What went wrong What to change
400 missing-media-type The request carried a body and no Content-Type. Add the header and resend.
415 unsupported-media-type The Content-Type is one this API does not read. Send JSON.
400 malformed-body The body did not parse. Fix the syntax.
422 invalid-query The body parsed and meant nothing this API can answer. Change the question. Resending will not help.

The difference that matters is the last row: 400 and 415 say resend it differently, and 422 says stop sending this.

The whole catalogue

Rather than list every type here — where the list would be a second copy that goes stale, and where the count would be wrong the day one is added — follow the type of any refusal you actually receive. Each page is generated from the catalogue that produces the response, so the two cannot disagree.

Anything the API can refuse with has a page. Anything without a page, it cannot refuse with.

Two caveats, both about addresses that are not resources of this API. The catalogue carries a too-many-requests type that nothing reaches — there is no rate limit, and Authentication says why. And /api/cron/feedback-rollup answers 401 with a plain-text body rather than a problem document; it is a scheduled job that happens to live under /api/, and it is outside the catalogue, the reference, and the promise at the top of this page.

---
title: "Errors"
description: "Every refusal is a problem document, and its type resolves to a page explaining it."
url: "https://lateano.com/developers/api/errors"
author: "Colin Lateano"
---

# Errors

Every refusal is a problem document, and its type resolves to a page explaining it.

**Every refusal is an RFC 9457 problem document**, served as
`application/problem+json`. Negotiate on the media type rather than on the status
line: it is the only signal that tells a problem document apart from a successful
body that happens to carry a `status` field.

```json
{
  "type": "https://lateano.com/api/problems/invalid-query",
  "title": "The query parsed but does not describe a question this resource can answer",
  "status": 422,
  "detail": "…what was wrong with this particular request…"
}
```

That `title` is the real one, character for character, which matters for the
reason the next section gives. The `detail` varies per request and is elided here
rather than invented — a sample string in that slot would be a sentence this API
does not produce.

## The type URI resolves

RFC 9457 permits `type` to be an opaque identifier. **This API gives that up
deliberately: every `type` is a real address that serves a page describing the
problem.** Fetch it and you get the status, the title, and what to do about it.

```sh
curl -sS https://lateano.com/api/problems/invalid-query
```

The URI is derived from the catalogue entry rather than written at the place the
error is raised, and the page is generated from the same entry. So a `type` that
resolves is a property of how the API is built, not of somebody having remembered
to write a page.

## Read `detail`, not `title`

`title` is a short summary that does **not** change between occurrences — it
identifies the _kind_ of failure and is safe to switch on. `detail` is what
varies: the field that was wrong, the value that was rejected, the header that
was missing. A client that logs only `title` throws away everything that would
let a person fix the request.

## Four failures that look like one

The commonest mistake in an API client is collapsing every 4xx to "bad request".
These are distinct because the caller's next move is distinct.

| Status | Type                     | What went wrong                                        | What to change                                |
| :----- | :----------------------- | :----------------------------------------------------- | :-------------------------------------------- |
| `400`  | `missing-media-type`     | The request carried a body and no `Content-Type`.      | Add the header and resend.                    |
| `415`  | `unsupported-media-type` | The `Content-Type` is one this API does not read.      | Send JSON.                                    |
| `400`  | `malformed-body`         | The body did not parse.                                | Fix the syntax.                               |
| `422`  | `invalid-query`          | The body parsed and meant nothing this API can answer. | Change the question. Resending will not help. |

The difference that matters is the last row: `400` and `415` say _resend it
differently_, and `422` says _stop sending this_.

## The whole catalogue

Rather than list every type here — where the list would be a second copy that
goes stale, and where the count would be wrong the day one is added — follow the
`type` of any refusal you actually receive. Each page is generated from the
catalogue that produces the response, so the two cannot disagree.

Anything the API can refuse with has a page. Anything without a page, it cannot
refuse with.

**Two caveats, both about addresses that are not resources of this API.** The
catalogue carries a `too-many-requests` type that nothing reaches — there is no
rate limit, and [Authentication](/developers/api/authentication) says why. And
`/api/cron/feedback-rollup` answers `401` with a plain-text body rather than a
problem document; it is a scheduled job that happens to live under `/api/`, and
it is outside the catalogue, the reference, and the promise at the top of this
page.