Source code for app.core.errors
"""Domain error hierarchy + RFC7807 problem+json mapping."""
from __future__ import annotations
from typing import Any
[docs]
class SfmApiError(Exception):
"""Base class for every domain error sfmapi raises.
Subclasses MUST set ``status_code`` (HTTP) and ``error_type`` (the
slug used to build ``type: https://sfmapi.github.io/errors/<slug>``
in the RFC 7807 envelope). The FastAPI exception handler in
``app/main.py::sfmapi_error_handler`` turns these into
``application/problem+json`` responses; never raise raw
``HTTPException`` from services — keep the wire shape consistent.
Free-form ``extras`` are merged into the envelope as top-level
keys (``capability`` on 501, ``retry_after`` on 429); see the
typed surface in :class:`~app.schemas.api.common.ProblemResponse`.
"""
status_code: int = 500
error_type: str = "internal"
title: str = "Internal error"
def __init__(self, detail: str = "", **extras: Any) -> None:
super().__init__(detail)
self.detail = detail or self.title
self.extras = extras
[docs]
def as_problem(self, instance: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"type": f"https://sfmapi.github.io/errors/{self.error_type}",
"title": self.title,
"status": self.status_code,
"detail": self.detail,
}
if instance:
out["instance"] = instance
out.update(self.extras)
return out
[docs]
class BadRequestError(SfmApiError):
"""400 — request couldn't be parsed or violates a non-schema invariant.
Use ``ValidationError`` (422) when the request shape is well-formed
but a field value is wrong; use this when the request couldn't
even be interpreted (e.g., malformed Content-Range, conflicting
query params)."""
status_code = 400
error_type = "bad_request"
title = "Bad request"
[docs]
class NotFoundError(SfmApiError):
"""404 — the addressed resource doesn't exist for this tenant.
Routes MUST raise this rather than returning ``None`` so the
failure shows up as ``application/problem+json``. Tenant scoping
is implicit: a row that exists under a different tenant looks
like 404 to the caller (see ``L2`` — multi-tenant boundary).
"""
status_code = 404
error_type = "not_found"
title = "Resource not found"
[docs]
class ConflictError(SfmApiError):
"""409 — the request conflicts with the current resource state.
Use for optimistic-concurrency / state-machine violations
(e.g., finalizing an upload that's already finalized, deleting a
project with active jobs). Use :class:`ValidationError` (422) when
the request itself is wrong; use this when the request would be
valid against a different server state.
"""
status_code = 409
error_type = "conflict"
title = "Conflict"
[docs]
class ValidationError(SfmApiError):
"""422 — semantic validation failed after parsing succeeded.
Distinct from FastAPI's :class:`fastapi.RequestValidationError`
(Pydantic schema-shape failures): this fires from inside services
when a value is well-typed but rejected on a domain rule
(cross-field consistency, foreign-key lookup, plausibility). Both
paths emit the same RFC 7807 ``problem+json`` shape — see ``L19``
for the structured ``errors[]`` invariant.
"""
status_code = 422
error_type = "validation"
title = "Validation error"
[docs]
class TenantViolationError(SfmApiError):
"""403 — tenant scoping was bypassed or auth resolution failed.
Raised by :func:`app.core.tenancy.current_tenant` when the
Authorization header is missing / unrecognized, and by repository
helpers that detect a tenant_id mismatch. See ``L2`` in
``decisions.md`` (multi-tenant from day 1, ``default`` until auth
lands). Once real auth ships, 401 (missing) and 403 (rejected)
will split — for now both surface here.
"""
status_code = 403
error_type = "tenant_violation"
title = "Tenant boundary violation"
[docs]
class CapabilityUnavailableError(SfmApiError):
"""The requested SfM feature isn't supported by the current backend.
Returns ``501 Not Implemented`` rather than 5xx because the request
itself is well-formed and the server is healthy — it just doesn't
expose the requested capability. The ``capability`` extra carries
the canonical feature name (see :mod:`app.core.capabilities`) so
clients can correlate with ``GET /v1/capabilities``.
"""
status_code = 501
error_type = "capability_unavailable"
title = "Capability not available in this deployment"
def __init__(self, *, capability: str, reason: str = "") -> None:
detail = reason or f"capability {capability!r} not supported by the current backend"
super().__init__(detail=detail, capability=capability)
[docs]
class PycolmapUnavailableError(CapabilityUnavailableError):
"""Backwards-compat: the colmap_mod backend can't load pycolmap.
Subclass of :class:`CapabilityUnavailableError` so callers that
catch the parent see this too. Status code stays 501 — the failure
is the same shape (a capability the deployment doesn't expose),
even though the reason is backend-specific.
"""
error_type = "pycolmap_unavailable"
title = "pycolmap not available in this deployment"
def __init__(self, reason: str = "") -> None:
super().__init__(capability="pycolmap", reason=reason or self.title)
[docs]
class QuotaExceededError(SfmApiError):
"""429 — caller hit a per-tenant or per-request rate / size cap.
Currently fires on oneshot request-body caps
(``oneshot_max_request_bytes``); Phase 5 plumbs in fair-share
scheduling + per-tenant job quotas through this same error. SDK
ergonomics shims interpret 429 as retryable when ``retry_after``
is set.
"""
status_code = 429
error_type = "quota_exceeded"
title = "Quota exceeded"
[docs]
class StorageError(SfmApiError):
"""507 Insufficient Storage — the backing store rejected a write.
Used for blob-store write failures, snapshot rename collisions,
workspace-out-of-space conditions. Distinct from a generic 500:
the request itself was valid, the storage layer couldn't hold it.
"""
status_code = 507
error_type = "storage"
title = "Storage error"