HTTP error mapping (route templates)¶
This page documents how SpecStar route templates map internal exceptions to HTTP responses.
All route templates use a shared helper (to_http_exception) that
provides consistent error mapping across every endpoint.
Exception mapping table¶
| Exception | HTTP | Meaning |
|---|---|---|
msgspec.ValidationError |
422 | Type-level validation |
specstar.types.ValidationError |
422 | Custom validation |
PermissionDeniedError |
403 | Access denied |
ResourceIsDeletedError |
410 | Soft-deleted (Gone) — distinct from "never existed" |
ResourceNotFoundError (other) |
404 | Resource / revision missing |
UniqueConstraintError |
409 | Unique field conflict (structured detail) |
ResourceConflictError (family) |
409 | Conflict |
Any other Exception |
400 | Bad request (fallback) |
The ResourceNotFoundError family includes:
ResourceIDNotFoundErrorResourceIsDeletedError— mapped specifically to 410 Gone (see below), not 404, so clients can distinguish "deleted" from "never existed"RevisionNotFoundErrorRevisionIDNotFoundError
The ResourceConflictError family includes:
UniqueConstraintError(structured JSON detail)DuplicateResourceErrorSchemaConflictErrorCannotModifyResourceError
Error matrix (quick overview)¶
| Route | 400 | 403 | 404 | 409 | 410 | 422 |
|---|---|---|---|---|---|---|
| GET | fallback | permission | not found | conflict | soft-deleted | — |
| POST | fallback | permission | not found | unique / conflict | — | validation |
| PUT | fallback | permission | not found | unique / conflict | soft-deleted | validation |
| PATCH | fallback | permission | not found | unique / conflict | soft-deleted | validation |
| DELETE | fallback | permission | not found | conflict | — | — |
| SWITCH | fallback | permission | not found | conflict | soft-deleted | — |
| RESTORE | fallback | permission | not found | conflict | — | — |
QB error conventions¶
When using the HTTP qb query parameter, SpecStar applies two explicit rules so clients can distinguish syntax problems from invalid parameter combinations.
| Situation | HTTP | Meaning | Typical trigger |
|---|---|---|---|
| Invalid QB expression | 400 | the QB string could not be parsed or uses unsupported operations | syntax error, unknown QB method, unsafe expression |
| QB parameter conflict | 422 | the request mixes QB mode with incompatible filter inputs | combining qb with data_conditions, conditions, sorts, or metadata filter params |
In QB mode, only limit and offset should be sent alongside qb. If you need delete status or other metadata filtering, put those conditions directly into the QB expression itself.
Read routes (get.py)¶
Canonical GET resource¶
GET /{model}/{resource_id}
(and deprecated aliases: /data, /meta, /full, /revision-info)
Errors pass through to_http_exception:
- 410 —
ResourceIsDeletedError(soft-deleted; pass?include_deleted=trueto bypass) - 404 —
ResourceIDNotFoundError,RevisionIDNotFoundError - 403 —
PermissionDeniedError - 400 — any other internal exception
Revision list¶
GET /{model}/{resource_id}/revision-list
Specific inline errors:
- 400 — invalid
sort,limit < 1,offset < 0 - 404 —
from_revision_idnot found
All other exceptions go through to_http_exception.
Blob content¶
GET /{model}/{resource_id}/blobs/{file_id}
The route performs a permission gate via resource_manager.get(resource_id).
Errors from that call go through to_http_exception:
- 403 —
PermissionDeniedError - 404 —
ResourceIDNotFoundError
Blob-specific errors:
- 404 — blob does not exist (
FileNotFoundError) - 400 — blob store not configured (
NotImplementedError) - 500 — blob data missing
Upload-session routes¶
POST /blobs/upload-sessions, GET /blobs/upload-sessions/{upload_id},
PUT /blobs/upload-sessions/{upload_id}/content,
POST /blobs/upload-sessions/{upload_id}/finalize,
POST /blobs/upload-sessions/{upload_id}/abort
- 501 — blob store not configured
- 404 —
upload_idnot found - 409 Conflict — invalid state transition (e.g. finalize without content, finalize twice, abort after finalization, upload content to a non-pending session)
- 400 — other errors from the blob store or route layer
- 204 — successful abort (no body)
Create routes (create.py)¶
POST /{model}
422 Unprocessable Entity¶
Validation errors (caught explicitly before the fallback):
msgspec.ValidationErrorspecstar.types.ValidationError
409 Conflict¶
Unique constraint violations:
UniqueConstraintError
Response body (detail) format:
{
"message": "Unique constraint violated: field 'email' value 'foo@bar.com' already exists",
"field": "email",
"conflicting_resource_id": "user_123"
}
Fallback¶
All other exceptions go through to_http_exception (403, 404, 409, or 400).
Update routes (update.py)¶
PUT /{model}/{resource_id}
400 Bad Request (inline)¶
change_statusis only allowed whenmode="modify"
422 Unprocessable Entity¶
Validation errors (caught explicitly):
msgspec.ValidationErrorspecstar.types.ValidationError
409 Conflict¶
UniqueConstraintError(structured detail, same as Create)CannotModifyResourceError(e.g. modifying a stable resource withoutmode="modify")
404 Not Found¶
ResourceIDNotFoundError— resource does not exist
Fallback¶
All other exceptions go through to_http_exception.
Patch routes (patch.py)¶
PATCH /{model}/{resource_id}
Supports RFC6902 patch operations: add, remove, replace, move, copy, test.
422 Unprocessable Entity¶
Validation errors (caught explicitly):
msgspec.ValidationErrorspecstar.types.ValidationError
409 Conflict¶
UniqueConstraintError(structured detail)
404 Not Found¶
ResourceIDNotFoundError
Fallback¶
All other exceptions go through to_http_exception.
Delete routes (delete.py)¶
DELETE /{model}/{resource_id}
All exceptions go through to_http_exception:
- 404 — resource not found
- 403 — permission denied
- 409 — conflict
- 400 — fallback
The same mapping applies to:
DELETE /{model}/{resource_id}/permanentlyDELETE /{model}(batch delete)POST /{model}/{resource_id}/restorePOST /{model}/restore(batch restore)
Switch routes (switch.py)¶
POST /{model}/{resource_id}/switch/{revision_id}
All exceptions go through to_http_exception:
- 404 — resource or revision not found
- 403 — permission denied
- 400 — fallback
Uniform error envelope (opt-in)¶
By default, detail shapes vary by endpoint:
| Status | Source | Default detail shape |
|---|---|---|
| 4xx | SpecStar errors (most) | string (e.g. "Resource 'x' not found.") |
| 409 | UniqueConstraintError |
dict {message, code, field, conflicting_resource_id} |
| 422 | FastAPI RequestValidationError |
list of {type, loc, msg, input, ...} |
For clients that want a single shape to dispatch on, opt into the structured error envelope:
With this on, every error response — SpecStar's domain errors and FastAPI's own validation errors — looks like:
{
"detail": {
"message": "Human-readable description",
"code": "RESOURCE_NOT_FOUND",
"field": "email", // when relevant
"conflicting_resource_id": "user_123", // when relevant
"errors": [{"type": "...", "loc": [...], ...}] // when from FastAPI 422
}
}
The code values match the table at the top of this page
(RESOURCE_NOT_FOUND, RESOURCE_GONE, VALIDATION_ERROR,
UNIQUE_CONSTRAINT, …). Default is off for backward compatibility
— existing clients see the same shapes as 0.10/0.11.
Implications for API clients¶
Consistent behavior across all routes¶
All routes now use the same error mapping:
- 403 → access denied
- 404 → resource or revision does not exist
- 409 → conflict (unique constraint, cannot modify, duplicate, schema conflict)
- 422 → data validation failure
- 400 → generic / unexpected error
Notes on specific semantics¶
PUTdoes not upsert.PUT /{model}/{resource_id}requires the resource to exist — an unknownresource_idreturns404, not a create. UsePOST /{model}if you want create-on-write semantics.- Soft-deleted resource →
410 Gone. Reading a soft-deleted resource returns410withdetail="Resource '<id>' is deleted.", distinct from the404returned when the id never existed. Pass?include_deleted=trueto read deleted resources via the same endpoint. - Unknown
revision_idonswitch→404with the parsed id in the body. The{revision_id}path segment is the full id form{model}:{resource_id}:{revision_number}(e.g.user:abc:3); passing the bare revision number is rejected with400and a hint.
UniqueConstraintError detail¶
409 responses from UniqueConstraintError include structured JSON:
{
"message": "Unique constraint violated: ...",
"field": "email",
"conflicting_resource_id": "user_123"
}
Other 409 responses have a plain string detail.
Implementation¶
The shared helper lives in specstar/crud/route_templates/exception_handlers.py:
Route templates use it as: