Error semantics
The governing rule is: 401 means "I do not know who you are"; 403 means "I know
who you are, and no."
| Condition | Status | Body |
|---|---|---|
| No credential, malformed, or revoked | 401 |
— |
| Key expired | 401 |
{"error": "key expired"} |
| Session expired | 401 |
{"error": "session expired"} |
| Bad password or locked account | 401 |
{"error": "invalid credentials"} |
| Insufficient role | 403 |
{"error": "insufficient role", "required": "admin_only", "have": "read_write"} |
| Source IP not allowlisted | 403 |
{"error": "key not authorized from this IP"} |
| Path outside key scope | 403 |
{"error": "key not authorized for this endpoint"} |
| CSRF missing or mismatched (cookie path only) | 403 |
— |
| Rate limit exceeded | 429 |
{"error": "rate_limit_exceeded", "group": …, "retry_after": …} plus a Retry-After header |
| Controller unreachable | 502 |
{"error": {"code": "CONTROLLER_UNREACHABLE", "message": …}} |
| Controller returned a non-JSON body | varies | {"error": {"code": "BAD_UPSTREAM", "message": …}} |
The direct client guidance follows from the table. Retry a 401 only after
re-minting a credential — never in a loop. Never retry a 403; it is a
policy decision and will not change on repetition. Honour Retry-After on
429. Treat 502 as a transient dependency failure and back off.
Note that a locked account and a wrong password return an identical 401 with an
identical body. The distinction is recorded in the audit log but is deliberately
not disclosed on the wire, because disclosing it enumerates valid usernames.