Skip to content

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:

  • ResourceIDNotFoundError
  • ResourceIsDeletedError — mapped specifically to 410 Gone (see below), not 404, so clients can distinguish "deleted" from "never existed"
  • RevisionNotFoundError
  • RevisionIDNotFoundError

The ResourceConflictError family includes:

  • UniqueConstraintError (structured JSON detail)
  • DuplicateResourceError
  • SchemaConflictError
  • CannotModifyResourceError

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:

  • 410ResourceIsDeletedError (soft-deleted; pass ?include_deleted=true to bypass)
  • 404ResourceIDNotFoundError, RevisionIDNotFoundError
  • 403PermissionDeniedError
  • 400 — any other internal exception

Revision list

GET /{model}/{resource_id}/revision-list

Specific inline errors:

  • 400 — invalid sort, limit < 1, offset < 0
  • 404from_revision_id not 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:

  • 403PermissionDeniedError
  • 404ResourceIDNotFoundError

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
  • 404upload_id not 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.ValidationError
  • specstar.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_status is only allowed when mode="modify"

422 Unprocessable Entity

Validation errors (caught explicitly):

  • msgspec.ValidationError
  • specstar.types.ValidationError

409 Conflict

  • UniqueConstraintError (structured detail, same as Create)
  • CannotModifyResourceError (e.g. modifying a stable resource without mode="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.ValidationError
  • specstar.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}/permanently
  • DELETE /{model} (batch delete)
  • POST /{model}/{resource_id}/restore
  • POST /{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:

spec = SpecStar(structured_errors=True)
# or
spec.configure(structured_errors=True)

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

  • PUT does not upsert. PUT /{model}/{resource_id} requires the resource to exist — an unknown resource_id returns 404, not a create. Use POST /{model} if you want create-on-write semantics.
  • Soft-deleted resource → 410 Gone. Reading a soft-deleted resource returns 410 with detail="Resource '<id>' is deleted.", distinct from the 404 returned when the id never existed. Pass ?include_deleted=true to read deleted resources via the same endpoint.
  • Unknown revision_id on switch404 with 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 with 400 and 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:

from specstar.crud.route_templates.exception_handlers import to_http_exception

Route templates use it as:

except (msgspec.ValidationError, ValidationError) as e:
    raise HTTPException(status_code=422, detail=str(e))
except UniqueConstraintError as e:
    raise_unique_conflict(e)
except Exception as e:
    raise to_http_exception(e)