Consolidate domain exceptions into app.exceptions

Move all shared domain exception classes to backend/app/exceptions.py and update services/routers to import the canonical exceptions. Update docs to reflect the shared exceptions source.
This commit is contained in:
2026-04-13 19:35:12 +02:00
parent 4b2e86edbb
commit a5674f9e4c
10 changed files with 199 additions and 381 deletions

View File

@@ -7,7 +7,6 @@ for fail2ban action configurations.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -18,7 +17,15 @@ from pathlib import Path
import structlog
from app.exceptions import ConfigWriteError, JailNotFoundInConfigError
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
JailNameError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_get_active_jail_names,
_parse_jails_sync,
@@ -33,59 +40,10 @@ from app.models.config import (
AssignActionRequest,
)
from app.utils import conffile_parser
from app.utils.async_utils import run_blocking
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found in ``action.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that was not found.
Args:
name: The action name that could not be located.
"""
self.name: str = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that already exists.
Args:
name: The action name that already exists.
"""
self.name: str = name
super().__init__(f"Action already exists: {name!r}")
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that cannot be deleted.
Args:
name: The action name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -108,15 +66,6 @@ _TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# ---------------------------------------------------------------------------
# Helper exceptions
# ---------------------------------------------------------------------------
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -601,7 +550,6 @@ async def list_actions(
``used_by_jails`` lists.
"""
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d)
@@ -676,7 +624,6 @@ async def get_action(
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read action content and return (content, has_local_override, source_path)."""
@@ -787,7 +734,6 @@ async def update_action(
content = conffile_parser.serialize_action_config(merged)
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_action_local_sync, action_d, base_name, content)
if do_reload:
@@ -840,7 +786,6 @@ async def create_action(
if conf_path.is_file() or local_path.is_file():
raise ActionAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
cfg = ActionConfig(
@@ -903,7 +848,6 @@ async def delete_action(
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -957,7 +901,6 @@ async def assign_action_to_jail(
_safe_jail_name(jail_name)
_safe_action_name(req.action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:
@@ -1035,7 +978,6 @@ async def remove_action_from_jail(
_safe_jail_name(jail_name)
_safe_action_name(action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:

View File

@@ -21,7 +21,6 @@ directory traversal.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -33,7 +32,23 @@ from typing import cast
import structlog
from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.helpers.jail_helpers import reload_jails
from app.models.config import (
ActionConfig,
@@ -58,6 +73,7 @@ from app.models.config import (
RollbackResponse,
)
from app.utils import conffile_parser
from app.utils.async_utils import run_blocking
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -120,90 +136,6 @@ _TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class JailNotFoundInConfigError(Exception):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name that was not found.
Args:
name: The jail name that could not be located.
"""
self.name: str = name
super().__init__(f"Jail not found in config files: {name!r}")
class JailAlreadyActiveError(Exception):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already active.
"""
self.name: str = name
super().__init__(f"Jail is already active: {name!r}")
class JailAlreadyInactiveError(Exception):
"""Raised when trying to deactivate a jail that is already inactive."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already inactive.
"""
self.name: str = name
super().__init__(f"Jail is already inactive: {name!r}")
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
class ConfigWriteError(Exception):
"""Raised when writing a ``.local`` override file fails."""
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -1098,8 +1030,9 @@ async def list_inactive_jails(
:class:`~app.models.config.InactiveJailListResponse` with all
inactive jails.
"""
loop = asyncio.get_event_loop()
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(_parse_jails_sync, Path(config_dir)
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(
_parse_jails_sync,
Path(config_dir),
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
@@ -1155,7 +1088,6 @@ async def activate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1207,7 +1139,8 @@ async def activate_jail(
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await run_blocking(lambda: local_path.read_bytes() if local_path.exists() else None,
original_content: bytes | None = await run_blocking(
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await run_blocking(_write_local_override_sync,
@@ -1351,7 +1284,6 @@ async def _rollback_activation_async(
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
@@ -1411,7 +1343,6 @@ async def deactivate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1467,7 +1398,6 @@ async def delete_jail_local_override(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1507,7 +1437,6 @@ async def validate_jail_config(
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await run_blocking(_validate_jail_config_sync,
Path(config_dir),
name,
@@ -1542,7 +1471,6 @@ async def rollback_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await run_blocking(_write_local_override_sync,
@@ -1596,19 +1524,6 @@ async def rollback_jail(
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found in ``filter.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that was not found.
Args:
name: The filter name that could not be located.
"""
self.name: str = name
super().__init__(f"Filter not found: {name!r}")
def _extract_filter_base_name(filter_raw: str) -> str:
"""Extract the base filter name from a raw fail2ban filter string.
@@ -1778,7 +1693,6 @@ async def list_filters(
``used_by_jails`` lists.
"""
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
# Run the synchronous scan in a thread-pool executor.
raw_filters: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_filters_sync, filter_d)
@@ -1855,7 +1769,6 @@ async def get_filter(
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read filter content and return (content, has_local_override, source_path)."""
@@ -1976,7 +1889,6 @@ async def update_filter(
content = conffile_parser.serialize_filter_config(merged)
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_filter_local_sync, filter_d, base_name, content)
if do_reload:
@@ -2033,7 +1945,6 @@ async def create_filter(
if conf_path.is_file() or local_path.is_file():
raise FilterAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
# Validate regex patterns.
@@ -2099,7 +2010,6 @@ async def delete_filter(
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -2154,7 +2064,6 @@ async def assign_filter_to_jail(
_safe_jail_name(jail_name)
_safe_filter_name(req.filter_name)
loop = asyncio.get_event_loop()
# Verify the jail exists in config.
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
@@ -2206,51 +2115,6 @@ async def assign_filter_to_jail(
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found in ``action.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that was not found.
Args:
name: The action name that could not be located.
"""
self.name: str = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that already exists.
Args:
name: The action name that already exists.
"""
self.name: str = name
super().__init__(f"Action already exists: {name!r}")
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that cannot be deleted.
Args:
name: The action name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
def _safe_action_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`ActionNameError`.
@@ -2642,7 +2506,6 @@ async def list_actions(
``used_by_jails`` lists.
"""
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d)
@@ -2717,7 +2580,6 @@ async def get_action(
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read action content and return (content, has_local_override, source_path)."""
@@ -2828,7 +2690,6 @@ async def update_action(
content = conffile_parser.serialize_action_config(merged)
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_action_local_sync, action_d, base_name, content)
if do_reload:
@@ -2881,7 +2742,6 @@ async def create_action(
if conf_path.is_file() or local_path.is_file():
raise ActionAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
cfg = ActionConfig(
@@ -2944,7 +2804,6 @@ async def delete_action(
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -2999,7 +2858,6 @@ async def assign_action_to_jail(
_safe_jail_name(jail_name)
_safe_action_name(req.action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:
@@ -3077,7 +2935,6 @@ async def remove_action_from_jail(
_safe_jail_name(jail_name)
_safe_action_name(action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:

View File

@@ -18,7 +18,16 @@ from pathlib import Path
import structlog
from app.exceptions import ConfigWriteError, FilterInvalidRegexError, JailNotFoundInConfigError
from app.exceptions import (
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailNameError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_get_active_jail_names,
_parse_jails_sync,
@@ -37,65 +46,11 @@ from app.utils import conffile_parser
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found in ``filter.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that was not found.
Args:
name: The filter name that could not be located.
"""
self.name: str = name
super().__init__(f"Filter not found: {name!r}")
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
# ---------------------------------------------------------------------------
# Additional helper functions for this service
# ---------------------------------------------------------------------------
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

View File

@@ -9,7 +9,6 @@ overrides in jail.d/*.local files.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -21,7 +20,14 @@ from typing import TYPE_CHECKING, cast
import structlog
from app.exceptions import JailNotFoundError
from app.exceptions import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_build_inactive_jail,
_get_active_jail_names,
@@ -38,6 +44,7 @@ from app.models.config import (
RollbackResponse,
)
from app.tasks.health_check import run_probe
from app.utils.async_utils import run_blocking
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.runtime_state import (
clear_activation_record,
@@ -76,58 +83,6 @@ _POST_RELOAD_PROBE_INTERVAL: float = 2.0
_POST_RELOAD_MAX_ATTEMPTS: int = 4
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class JailNotFoundInConfigError(Exception):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name that was not found.
Args:
name: The jail name that could not be located.
"""
self.name: str = name
super().__init__(f"Jail not found in config files: {name!r}")
class JailAlreadyActiveError(Exception):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already active.
"""
self.name: str = name
super().__init__(f"Jail is already active: {name!r}")
class JailAlreadyInactiveError(Exception):
"""Raised when trying to deactivate a jail that is already inactive."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already inactive.
"""
self.name: str = name
super().__init__(f"Jail is already inactive: {name!r}")
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
class ConfigWriteError(Exception):
"""Raised when writing a ``.local`` override file fails."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -502,8 +457,9 @@ async def list_inactive_jails(
:class:`~app.models.config.InactiveJailListResponse` with all
inactive jails.
"""
loop = asyncio.get_event_loop()
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(_parse_jails_sync, Path(config_dir)
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(
_parse_jails_sync,
Path(config_dir),
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
@@ -587,7 +543,6 @@ async def _activate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -639,7 +594,8 @@ async def _activate_jail(
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await run_blocking(lambda: local_path.read_bytes() if local_path.exists() else None,
original_content: bytes | None = await run_blocking(
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await run_blocking(_write_local_override_sync,
@@ -783,7 +739,6 @@ async def _rollback_activation_async(
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
@@ -860,7 +815,6 @@ async def _deactivate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -916,7 +870,6 @@ async def delete_jail_local_override(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -956,7 +909,6 @@ async def validate_jail_config(
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await run_blocking(_validate_jail_config_sync,
Path(config_dir),
name,
@@ -1008,7 +960,6 @@ async def _rollback_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await run_blocking(_write_local_override_sync,