| """ |
| domain/exceptions/domain_exceptions.py |
| βββββββββββββββββββββββββββββββββββββββ |
| Domain-specific exception hierarchy. |
| |
| All exceptions here represent business-rule violations β they are completely |
| framework-agnostic (no HTTP codes, no FastAPI imports). |
| |
| Hierarchy: |
| DomainException (base) |
| βββ InvalidSignalError β PPG data fails domain validation β 400 |
| βββ PredictionOutOfRangeError β Predicted BP is physiologically impossible β 422 |
| βββ EntityNotFoundError β Entity lookup returned nothing β 404 |
| βββ ConflictError β Duplicate/unique constraint violation β 409 |
| βββ DatabaseError β DB unavailable or unrecoverable query err β 503 |
| """ |
| from __future__ import annotations |
|
|
|
|
| class DomainException(Exception): |
| """ |
| Base class for all domain-layer exceptions. |
| |
| Catch this if you want to handle any business-rule violation without |
| knowing the specific type. |
| """ |
|
|
| def __init__(self, message: str, context: dict | None = None) -> None: |
| super().__init__(message) |
| self.message = message |
| self.context: dict = context or {} |
|
|
| def __repr__(self) -> str: |
| return f"{self.__class__.__name__}(message={self.message!r}, context={self.context})" |
|
|
|
|
| class InvalidSignalError(DomainException): |
| """ |
| Raised when a PPG signal fails domain validation. |
| |
| Examples: |
| - Sampling rate is outside the accepted range |
| - Signal is too short / too long |
| - ``ppg_values`` list is empty |
| - ``duration_seconds`` does not match the number of samples |
| |
| HTTP Status: 400 Bad Request |
| """ |
|
|
| def __init__(self, field: str, value: object, reason: str) -> None: |
| message = f"Invalid PPG signal β field '{field}': {reason} (got {value!r})" |
| super().__init__(message, context={"field": field, "value": value, "reason": reason}) |
| self.field = field |
| self.value = value |
| self.reason = reason |
|
|
|
|
| class PredictionOutOfRangeError(DomainException): |
| """ |
| Raised when a model's prediction is physiologically implausible. |
| |
| Examples: |
| - Predicted SBP < 60 mmHg or > 260 mmHg |
| - Predicted DBP < 30 mmHg or > 160 mmHg |
| - SBP < DBP (pulse pressure is negative) |
| |
| HTTP Status: 422 Unprocessable Entity |
| """ |
|
|
| def __init__(self, predicted_sbp: float, predicted_dbp: float, reason: str) -> None: |
| message = ( |
| f"Prediction out of physiological range β " |
| f"SBP={predicted_sbp} mmHg, DBP={predicted_dbp} mmHg: {reason}" |
| ) |
| super().__init__( |
| message, |
| context={ |
| "predicted_sbp": predicted_sbp, |
| "predicted_dbp": predicted_dbp, |
| "reason": reason, |
| }, |
| ) |
| self.predicted_sbp = predicted_sbp |
| self.predicted_dbp = predicted_dbp |
| self.reason = reason |
|
|
|
|
| class EntityNotFoundError(DomainException): |
| """ |
| Raised when a repository lookup fails to find the requested entity. |
| |
| HTTP Status: 404 Not Found |
| |
| Args: |
| entity_type: Class name of the entity (e.g. ``"PPGSignal"``). |
| entity_id: The ID that was looked up. |
| """ |
|
|
| def __init__(self, entity_type: str, entity_id: str) -> None: |
| message = f"{entity_type} with id='{entity_id}' not found." |
| super().__init__(message, context={"entity_type": entity_type, "entity_id": entity_id}) |
| self.entity_type = entity_type |
| self.entity_id = entity_id |
|
|
|
|
| class ConflictError(DomainException): |
| """ |
| Raised when an insert/update violates a unique constraint in the database. |
| |
| Typical causes: |
| - Duplicate PPG signal ID (re-submission of the same record) |
| - Unique index violation on (user_id, timestamp) composite key |
| |
| HTTP Status: 409 Conflict |
| |
| Args: |
| entity_type: Class name of the conflicting entity (e.g. ``"PPGSignal"``). |
| detail: Human-readable description of the conflict. |
| """ |
|
|
| def __init__(self, entity_type: str, detail: str) -> None: |
| message = f"Conflict on {entity_type}: {detail}" |
| super().__init__(message, context={"entity_type": entity_type, "detail": detail}) |
| self.entity_type = entity_type |
| self.detail = detail |
|
|
|
|
| class DatabaseError(DomainException): |
| """ |
| Raised when the database is unreachable or an unrecoverable query error occurs. |
| |
| Wraps SQLAlchemy/asyncpg low-level errors so that domain and application |
| layers never import SQLAlchemy directly. |
| |
| HTTP Status: 503 Service Unavailable |
| |
| Args: |
| operation: The repository operation that failed (e.g. ``"add"``, ``"get_by_id"``). |
| reason: Human-readable description of what went wrong. |
| """ |
|
|
| def __init__(self, operation: str, reason: str) -> None: |
| message = f"Database error during '{operation}': {reason}" |
| super().__init__(message, context={"operation": operation, "reason": reason}) |
| self.operation = operation |
| self.reason = reason |
|
|