Implement centralized exception handling and validation

- Add custom exception classes for structured error handling
- Implement global exception handlers in FastAPI application
- Add comprehensive request/response validation
- Create exception contract tests for validation
- Update backend development documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-27 18:52:12 +02:00
parent 2e221f6852
commit afc1e44e99
5 changed files with 500 additions and 98 deletions

View File

@@ -1,9 +1,81 @@
"""Shared domain exception classes used across routers and services."""
"""Shared domain exception classes used across routers and services.
Exception Taxonomy
==================
All domain exceptions inherit from one of these base categories:
- **NotFoundError** (404): Domain entity not found
- **BadRequestError** (400): Invalid input, validation failure, invalid identifiers
- **ConflictError** (409): State conflict, resource already exists, invalid state transition
- **OperationError** (500): Operation failure, write errors
- **ServiceUnavailableError** (503): Infrastructure/external service issues
Service exceptions inherit from the appropriate category, allowing routers to
handle categories rather than individual exception types. Exception handlers in
main.py register only base category types.
Example:
def get_jail(name: str) -> Jail:
# Raises JailNotFoundError (subclass of NotFoundError)
...
@app.exception_handler(NotFoundError)
async def handle_not_found(request, exc):
return JSONResponse(status_code=404, content={"detail": str(exc)})
See Backend-Development.md for the complete exception contract.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Exception Base Classes (Categories)
# ---------------------------------------------------------------------------
class JailNotFoundError(Exception):
class DomainError(Exception):
"""Base class for all domain exceptions."""
pass
class NotFoundError(DomainError):
"""Raised when a requested domain entity is not found. HTTP 404."""
pass
class BadRequestError(DomainError):
"""Raised for invalid input, validation failures, or invalid identifiers. HTTP 400."""
pass
class ConflictError(DomainError):
"""Raised for state conflicts or resource constraints. HTTP 409."""
pass
class OperationError(DomainError):
"""Raised when a domain operation fails (write, update, etc.). HTTP 500."""
pass
class ServiceUnavailableError(DomainError):
"""Raised for infrastructure or external service issues. HTTP 503."""
pass
# ---------------------------------------------------------------------------
# Jail-Specific Exceptions
# ---------------------------------------------------------------------------
class JailNotFoundError(NotFoundError):
"""Raised when a requested jail name does not exist."""
def __init__(self, name: str) -> None:
@@ -11,23 +83,23 @@ class JailNotFoundError(Exception):
super().__init__(f"Jail not found: {name!r}")
class JailOperationError(Exception):
"""Raised when a fail2ban jail operation fails."""
class JailOperationError(ConflictError):
"""Raised when a jail state operation fails (e.g. start/stop already in progress)."""
class ConfigValidationError(Exception):
class ConfigValidationError(BadRequestError):
"""Raised when config values fail validation before applying."""
class ConfigOperationError(Exception):
class ConfigOperationError(BadRequestError):
"""Raised when a config payload update or command fails."""
class ConfigDirError(Exception):
class ConfigDirError(ServiceUnavailableError):
"""Raised when the fail2ban config directory is missing or inaccessible."""
class ConfigFileNotFoundError(Exception):
class ConfigFileNotFoundError(NotFoundError):
"""Raised when a requested config file does not exist."""
def __init__(self, filename: str) -> None:
@@ -40,7 +112,7 @@ class ConfigFileNotFoundError(Exception):
super().__init__(f"Config file not found: {filename!r}")
class ConfigFileExistsError(Exception):
class ConfigFileExistsError(ConflictError):
"""Raised when trying to create a file that already exists."""
def __init__(self, filename: str) -> None:
@@ -53,19 +125,19 @@ class ConfigFileExistsError(Exception):
super().__init__(f"Config file already exists: {filename!r}")
class ConfigFileWriteError(Exception):
class ConfigFileWriteError(OperationError):
"""Raised when a file cannot be written (permissions, disk full, etc.)."""
class ConfigFileNameError(Exception):
class ConfigFileNameError(BadRequestError):
"""Raised when a supplied filename is invalid or unsafe."""
class ServerOperationError(Exception):
class ServerOperationError(BadRequestError):
"""Raised when a server control command (e.g. refresh) fails."""
class Fail2BanConnectionError(Exception):
class Fail2BanConnectionError(ServiceUnavailableError):
"""Raised when the fail2ban socket is unreachable or returns an error."""
def __init__(self, message: str, socket_path: str) -> None:
@@ -79,11 +151,11 @@ class Fail2BanConnectionError(Exception):
super().__init__(f"{message} (socket: {socket_path})")
class Fail2BanProtocolError(Exception):
class Fail2BanProtocolError(ServiceUnavailableError):
"""Raised when the response from fail2ban cannot be parsed."""
class FilterInvalidRegexError(Exception):
class FilterInvalidRegexError(BadRequestError):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
@@ -93,7 +165,7 @@ class FilterInvalidRegexError(Exception):
super().__init__(f"Invalid regex {pattern!r}: {error}")
class JailNotFoundInConfigError(Exception):
class JailNotFoundInConfigError(NotFoundError):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
@@ -101,7 +173,7 @@ class JailNotFoundInConfigError(Exception):
super().__init__(f"Jail not found in config: {name!r}")
class ConfigWriteError(Exception):
class ConfigWriteError(OperationError):
"""Raised when writing a configuration file modification fails."""
def __init__(self, message: str) -> None:
@@ -109,11 +181,11 @@ class ConfigWriteError(Exception):
super().__init__(message)
class JailNameError(Exception):
class JailNameError(BadRequestError):
"""Raised when a jail name contains invalid characters."""
class JailAlreadyActiveError(Exception):
class JailAlreadyActiveError(ConflictError):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
@@ -121,7 +193,7 @@ class JailAlreadyActiveError(Exception):
super().__init__(f"Jail is already active: {name!r}")
class JailAlreadyInactiveError(Exception):
class JailAlreadyInactiveError(ConflictError):
"""Raised when trying to deactivate a jail that is already inactive."""
def __init__(self, name: str) -> None:
@@ -129,7 +201,7 @@ class JailAlreadyInactiveError(Exception):
super().__init__(f"Jail is already inactive: {name!r}")
class FilterNotFoundError(Exception):
class FilterNotFoundError(NotFoundError):
"""Raised when the requested filter name is not found."""
def __init__(self, name: str) -> None:
@@ -137,7 +209,7 @@ class FilterNotFoundError(Exception):
super().__init__(f"Filter not found: {name!r}")
class FilterAlreadyExistsError(Exception):
class FilterAlreadyExistsError(ConflictError):
"""Raised when trying to create a filter whose `.conf` or `.local` already exists."""
def __init__(self, name: str) -> None:
@@ -145,11 +217,11 @@ class FilterAlreadyExistsError(Exception):
super().__init__(f"Filter already exists: {name!r}")
class FilterNameError(Exception):
class FilterNameError(BadRequestError):
"""Raised when a filter name contains invalid characters."""
class FilterReadonlyError(Exception):
class FilterReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
def __init__(self, name: str) -> None:
@@ -159,7 +231,7 @@ class FilterReadonlyError(Exception):
)
class ActionNotFoundError(Exception):
class ActionNotFoundError(NotFoundError):
"""Raised when the requested action name is not found."""
def __init__(self, name: str) -> None:
@@ -167,7 +239,7 @@ class ActionNotFoundError(Exception):
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
class ActionAlreadyExistsError(ConflictError):
"""Raised when trying to create an action whose `.conf` or `.local` already exists."""
def __init__(self, name: str) -> None:
@@ -175,11 +247,11 @@ class ActionAlreadyExistsError(Exception):
super().__init__(f"Action already exists: {name!r}")
class ActionNameError(Exception):
class ActionNameError(BadRequestError):
"""Raised when an action name contains invalid characters."""
class ActionReadonlyError(Exception):
class ActionReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` action with no `.local` override."""
def __init__(self, name: str) -> None: