Appearance
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.toolExpected 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:
| Priority | Method | Example |
|---|---|---|
| 1 | Exact class lookup in EXCEPTION_STATUS_MAP | AgentNotFoundError -> 404 |
| 2 | MRO walk (parent classes) in EXCEPTION_STATUS_MAP | Unmapped subclass of AgentError -> 500 |
| 3 | Suffix match on class name | *NotFoundError -> 404, *ValidationError -> 400, *AccessDeniedError -> 403, *ExecutionError -> 500 |
| 4 | Default | 500 |
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 ​
| Suffix | HTTP Status | Use for |
|---|---|---|
*Error (base) | 500 | Unexpected domain errors |
*NotFoundError | 404 | Resource does not exist |
*ValidationError | 400 | Invalid input or business rule violation |
*AccessDeniedError | 403 | Insufficient permissions |
*ExecutionError | 500 | Runtime failure during processing |
Rules ​
- Services raise
CoreErrorsubclasses only -- neverHTTPExceptionincore/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_idfor programmatic access