Source code for loom.core.errors.errors
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import Any
from loom.core.errors.codes import ErrorCode
[docs]
class LoomError(Exception):
"""Base class for all framework errors.
Carries a machine-readable ``code`` used by transport adapters to
produce appropriate responses (HTTP status, dead-letter routing, etc.)
without coupling the domain to any transport layer.
Args:
message: Human-readable description of the error.
code: Machine-readable discriminator (e.g. ``"not_found"``).
Example::
raise NotFound("User", id=42)
"""
def __init__(self, message: str, *, code: str) -> None:
self.message = message
self.code = code
super().__init__(message)
[docs]
class DomainError(LoomError):
"""Base class for all business-logic errors.
Raised inside UseCase execution. Transport adapters catch and convert
these to their own error representations.
"""
[docs]
class SystemError(LoomError):
"""Base class for infrastructure and unexpected errors.
Raised when a repository, external service, or internal subsystem
fails. Transport adapters may retry on SystemError and dead-letter
on DomainError.
Args:
message: Description of the system failure.
"""
def __init__(self, message: str) -> None:
super().__init__(message, code=ErrorCode.SYSTEM_ERROR)
[docs]
class NotFound(DomainError):
"""Raised when a requested entity does not exist.
Emitted automatically by ``LoadStep`` when a repository returns
``None``. May also be raised explicitly inside ``execute`` for
semantic not-found conditions.
Args:
entity: Name of the missing entity type (e.g. ``"User"``).
id: Lookup key that yielded no result.
Example::
raise NotFound("User", id=42)
"""
def __init__(self, entity: str, *, id: Any) -> None:
self.entity = entity
self.id = id
super().__init__(f"{entity} with id={id!r} not found", code=ErrorCode.NOT_FOUND)
[docs]
class Unauthenticated(DomainError):
"""Raised when an action requires a verified caller and there is none.
Distinct from :class:`Forbidden`: the caller can fix an ``Unauthenticated``
by presenting credentials, but cannot fix a ``Forbidden`` at all. Transport
adapters map it to ``401`` with an authentication challenge.
Args:
message: Description of the missing authentication.
Example::
raise Unauthenticated("This endpoint requires an authenticated caller.")
"""
def __init__(self, message: str = "Authentication required") -> None:
super().__init__(message, code=ErrorCode.UNAUTHENTICATED)
[docs]
class Forbidden(DomainError):
"""Raised when an action is not permitted for the caller.
Args:
message: Description of the access restriction.
reason: Optional machine-readable cause a client can act on, such as
``"mfa_required"``. ``None`` when the refusal has no specific cause.
missing: The permissions the caller lacks, in the order given; empty when
the authorizer does not tell them.
Both are keyword-only and public: the REST adapter puts them in the 403 body.
Never put token claims, tenant data or anything the caller may not learn in them;
leave them out where the refusal must not become an oracle for the policy.
Raises:
ValueError: If ``reason`` is not a non-blank string, ``missing`` is a single
``str`` or ``bytes``, or an item of ``missing`` is not a non-blank string.
"""
def __init__(
self,
message: str = "Forbidden",
*,
reason: str | None = None,
missing: Iterable[str] = (),
) -> None:
if reason is not None and (not isinstance(reason, str) or not reason.strip()):
raise ValueError("Forbidden.reason must be a non-blank string or None")
if isinstance(missing, (str, bytes)):
raise ValueError(
"Forbidden.missing takes an iterable of permissions, not one str or bytes"
)
permissions = tuple(missing)
if any(not isinstance(item, str) or not item.strip() for item in permissions):
raise ValueError("Forbidden.missing items must be non-blank strings")
super().__init__(message, code=ErrorCode.FORBIDDEN)
self.reason = reason
self.missing: tuple[str, ...] = permissions
[docs]
class Conflict(DomainError):
"""Raised when an operation conflicts with existing state.
Args:
message: Description of the conflicting condition.
"""
def __init__(self, message: str) -> None:
super().__init__(message, code=ErrorCode.CONFLICT)
[docs]
class RuleViolation(DomainError):
"""Raised when a single business rule is violated.
Args:
field: The field that caused the violation.
message: Human-readable description of the violation.
Example::
raise RuleViolation("email", "Disposable emails not allowed")
"""
def __init__(self, field: str, message: str) -> None:
self.field = field
# Override message attr with just the violation message (not field-prefixed)
# so adapters can access field and message independently.
super().__init__(f"{field}: {message}", code=ErrorCode.RULE_VIOLATION)
self.message = message
[docs]
class RuleViolations(DomainError):
"""Raised when one or more business rules are violated.
Accumulates all violations from rule evaluation instead of failing
fast on the first violation.
Args:
violations: Sequence of individual rule violations.
"""
def __init__(self, violations: Sequence[RuleViolation]) -> None:
self.violations: tuple[RuleViolation, ...] = tuple(violations)
messages = "; ".join(str(v) for v in self.violations)
super().__init__(messages, code=ErrorCode.RULE_VIOLATIONS)