Skip to content

Adding Domain Exceptions ​

Gnosari uses a domain exception hierarchy to separate business logic from HTTP concerns. Services raise CoreError subclasses; the API layer maps them to RFC 9457 Problem Details responses automatically.


3-Step Process ​

Step 1: Define the Exception ​

Add your exception class to core/exceptions.py, extending the appropriate domain base:

python
# core/exceptions.py

class WidgetError(CoreError):
    """Base exception for widget-related errors."""

class WidgetNotFoundError(WidgetError):
    """Raised when a widget cannot be found."""
    def __init__(self, message: str = "Widget not found", *, widget_id: int | None = None):
        self.widget_id = widget_id
        super().__init__(message)

class WidgetValidationError(WidgetError):
    """Raised when widget data fails validation."""

class WidgetAccessDeniedError(WidgetError):
    """Raised when access to a widget is denied."""

Step 2: Register in EXCEPTION_STATUS_MAP ​

Add the import and mapping entry in api/app/core/error_handlers.py:

python
from core.exceptions import (
    # ... existing imports ...
    WidgetAccessDeniedError,
    WidgetError,
    WidgetNotFoundError,
    WidgetValidationError,
)

EXCEPTION_STATUS_MAP: dict[type[CoreError], int] = {
    # ... existing entries ...
    # Widget domain
    WidgetError: 500,
    WidgetNotFoundError: 404,
    WidgetValidationError: 400,
    WidgetAccessDeniedError: 403,
}

Step 3: Verify ​

Raise the exception from a service method and confirm the API returns the correct RFC 9457 response:

bash
curl -s http://localhost:8000/api/v1/widgets/999 | python -m json.tool

Expected response:

json
{
  "type": "https://api.gnosari.com/errors/widget-not-found",
  "title": "Widget Not Found",
  "status": 404,
  "detail": "Widget not found",
  "instance": "/api/v1/widgets/999"
}

Status Code Resolution Order ​

When a CoreError subclass is raised, the handler resolves the HTTP status code using this algorithm:

PriorityMethodExample
1Exact class lookup in EXCEPTION_STATUS_MAPAgentNotFoundError -> 404
2MRO walk (parent classes) in EXCEPTION_STATUS_MAPUnmapped subclass of AgentError -> 500
3Suffix match on class name*NotFoundError -> 404, *ValidationError -> 400, *AccessDeniedError -> 403, *ExecutionError -> 500
4Default500

If you skip step 2 (no explicit mapping), suffix matching will usually guess correctly. Explicit mapping is still recommended for clarity and to avoid surprises.


Naming Conventions ​

SuffixHTTP StatusUse for
*Error (base)500Unexpected domain errors
*NotFoundError404Resource does not exist
*ValidationError400Invalid input or business rule violation
*AccessDeniedError403Insufficient permissions
*ExecutionError500Runtime failure during processing

Rules ​

  • Services raise CoreError subclasses only -- never HTTPException in core/services/
  • HTTP mapping lives in error_handlers.py -- the API layer owns status codes, not the domain
  • Always include a default message -- def __init__(self, message: str = "Widget not found"):
  • Store structured context as attributes -- self.widget_id = widget_id for programmatic access