API errors are django-ninja's default shape, not the standardised one: make them RFC 9457 problem documents #296

Closed
opened 2026-09-21 15:36:16 +00:00 by tiagoagueda · 0 comments
Owner

Every refusal the API makes comes back as django-ninja's default: {"detail": "..."} with
Content-Type: application/json. It is consistent, and the OpenAPI description describes
it, so a generated client copes. What it is not is the standardised shape — and a client
written against Postulo therefore needs Postulo-specific error handling, which is the one
thing an OpenAPI description is supposed to spare it.

RFC 9457 (Problem Details for HTTP APIs, which obsoletes RFC 7807) is that shape:
application/problem+json, with type, title, status, detail and instance, plus
extension members.

What changes

detail is already an RFC 9457 member and already a sentence, so for most refusals this is
additive: a client reading detail keeps working.

Member What Postulo puts there
type A URI naming the problem type, for the refusals a client acts on differently. about:blank where the status code already says everything
title A short label for the type, the same for every occurrence
status The status code, repeated in the body as the RFC asks
detail What is wrong with this request — the sentence that is there today
instance The address that refused

And the types worth naming, because a client branches on them:

type Status Extension members
validation-failed 422 errors — the per-field list
rate-limited 429 retry_after, matching the header
insufficient-scope 403 scope — the one the token lacks
idempotency-key-in-use 409
idempotency-key-reused 422

The one breaking change

A validation failure's detail is a sentence, not a list. Today it is the raw Pydantic
error list, which RFC 9457 does not allow — detail is a string there. The list moves to
an errors extension member, unchanged in shape.

A client that reads response.json()["detail"] and prints it is unaffected. A client that
iterates it must read errors instead. This wants a ### ⚠️ Breaking changelog entry with
that instruction.

Content-Type also becomes application/problem+json on refusals. A client checking for
exactly application/json will need +json suffix matching, which is what RFC 6839 says
to do anyway.

Not in scope

  • instance naming a unique occurrence. Nothing here mints a request id, and an id nobody
    logs is worse than the address, which at least says where. If request ids arrive later,
    this is where they surface.
  • Translating title. detail stays translated, because a person debugging reads it.
    title is a label for the type — the RFC says it SHOULD be the same for every
    occurrence — and a client that switches on it must not have it move with
    Accept-Language. That is what type is for, and title follows it.

Done when

  • Refusals from every raise site — HttpError, ValidationError, Http404, the throttle
    handler — come back as problem documents, from handlers on the NinjaAPI rather than
    from changes at each of the twenty-odd raise sites.
  • The OpenAPI description carries the Problem schema, so a generated client has a type
    for it.
  • The API on the wiki documents the envelope and lists the named types.
Every refusal the API makes comes back as django-ninja's default: `{"detail": "..."}` with `Content-Type: application/json`. It is consistent, and the OpenAPI description describes it, so a generated client copes. What it is not is the standardised shape — and a client written against Postulo therefore needs Postulo-specific error handling, which is the one thing an OpenAPI description is supposed to spare it. **RFC 9457** (*Problem Details for HTTP APIs*, which obsoletes RFC 7807) is that shape: `application/problem+json`, with `type`, `title`, `status`, `detail` and `instance`, plus extension members. ## What changes `detail` is already an RFC 9457 member and already a sentence, so for most refusals this is **additive**: a client reading `detail` keeps working. | Member | What Postulo puts there | | --- | --- | | `type` | A URI naming the problem type, for the refusals a client acts on differently. `about:blank` where the status code already says everything | | `title` | A short label for the *type*, the same for every occurrence | | `status` | The status code, repeated in the body as the RFC asks | | `detail` | What is wrong with *this* request — the sentence that is there today | | `instance` | The address that refused | And the types worth naming, because a client branches on them: | `type` | Status | Extension members | | --- | --- | --- | | `validation-failed` | 422 | `errors` — the per-field list | | `rate-limited` | 429 | `retry_after`, matching the header | | `insufficient-scope` | 403 | `scope` — the one the token lacks | | `idempotency-key-in-use` | 409 | | | `idempotency-key-reused` | 422 | | ## The one breaking change **A validation failure's `detail` is a sentence, not a list.** Today it is the raw Pydantic error list, which RFC 9457 does not allow — `detail` is a string there. The list moves to an `errors` extension member, unchanged in shape. A client that reads `response.json()["detail"]` and prints it is unaffected. A client that iterates it must read `errors` instead. This wants a `### ⚠️ Breaking` changelog entry with that instruction. `Content-Type` also becomes `application/problem+json` on refusals. A client checking for exactly `application/json` will need `+json` suffix matching, which is what RFC 6839 says to do anyway. ## Not in scope - `instance` naming a unique occurrence. Nothing here mints a request id, and an id nobody logs is worse than the address, which at least says where. If request ids arrive later, this is where they surface. - Translating `title`. `detail` stays translated, because a person debugging reads it. `title` is a label for the type — the RFC says it SHOULD be the same for every occurrence — and a client that switches on it must not have it move with `Accept-Language`. That is what `type` is for, and `title` follows it. ## Done when - Refusals from every raise site — `HttpError`, `ValidationError`, `Http404`, the throttle handler — come back as problem documents, from handlers on the `NinjaAPI` rather than from changes at each of the twenty-odd raise sites. - The OpenAPI description carries the `Problem` schema, so a generated client has a type for it. - *The API* on the wiki documents the envelope and lists the named types.
tiagoagueda added this to the 0.4.0 milestone 2026-09-21 15:36:16 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#296
No description provided.