## 27) Error response body shape is inconsistent

This commit is contained in:
2026-04-28 22:28:02 +02:00
parent a2129bb9bd
commit 1e2576af2a
16 changed files with 632 additions and 99 deletions

View File

@@ -10,11 +10,17 @@ All domain exceptions inherit from one of these base categories:
- **ConflictError** (409): State conflict, resource already exists, invalid state transition
- **OperationError** (500): Operation failure, write errors
- **ServiceUnavailableError** (503): Infrastructure/external service issues
- **AuthenticationError** (401): Authentication or authorization failure
- **RateLimitError** (429): Rate limit exceeded
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.
Every exception class has:
- **error_code**: A machine-readable error code for client-side branching
- **get_error_metadata()**: Returns structured metadata for the API response
Example:
def get_jail(name: str) -> Jail:
# Raises JailNotFoundError (subclass of NotFoundError)
@@ -22,52 +28,84 @@ Example:
@app.exception_handler(NotFoundError)
async def handle_not_found(request, exc):
return JSONResponse(status_code=404, content={"detail": str(exc)})
return JSONResponse(status_code=404, content=ErrorResponse(
code=exc.error_code,
detail=str(exc),
metadata=exc.get_error_metadata()
).model_dump())
See Backend-Development.md for the complete exception contract.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Exception Base Classes (Categories)
# ---------------------------------------------------------------------------
class DomainError(Exception):
"""Base class for all domain exceptions."""
"""Base class for all domain exceptions.
All domain exceptions must:
1. Define an `error_code` class attribute (machine-readable error code)
2. Implement `get_error_metadata()` to return structured error context
"""
pass
error_code: str = "internal_error"
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
"""Return structured metadata for the API error response.
Subclasses should override to expose only safe, relevant metadata.
Returns:
A dictionary of metadata key-value pairs safe for client consumption.
"""
return {}
class NotFoundError(DomainError):
"""Raised when a requested domain entity is not found. HTTP 404."""
pass
error_code: str = "not_found"
class BadRequestError(DomainError):
"""Raised for invalid input, validation failures, or invalid identifiers. HTTP 400."""
pass
error_code: str = "invalid_input"
class ConflictError(DomainError):
"""Raised for state conflicts or resource constraints. HTTP 409."""
pass
error_code: str = "conflict"
class OperationError(DomainError):
"""Raised when a domain operation fails (write, update, etc.). HTTP 500."""
pass
error_code: str = "operation_failed"
class ServiceUnavailableError(DomainError):
"""Raised for infrastructure or external service issues. HTTP 503."""
pass
error_code: str = "service_unavailable"
class AuthenticationError(DomainError):
"""Raised for authentication or authorization failures. HTTP 401."""
error_code: str = "authentication_required"
class RateLimitError(DomainError):
"""Raised when a client exceeds rate limits. HTTP 429."""
error_code: str = "rate_limit_exceeded"
# ---------------------------------------------------------------------------
@@ -78,30 +116,45 @@ class ServiceUnavailableError(DomainError):
class JailNotFoundError(NotFoundError):
"""Raised when a requested jail name does not exist."""
error_code: str = "jail_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"jail_name": self.name}
class JailOperationError(ConflictError):
"""Raised when a jail state operation fails (e.g. start/stop already in progress)."""
error_code: str = "jail_operation_failed"
class ConfigValidationError(BadRequestError):
"""Raised when config values fail validation before applying."""
error_code: str = "config_validation_failed"
class ConfigOperationError(BadRequestError):
"""Raised when a config payload update or command fails."""
error_code: str = "config_operation_failed"
class ConfigDirError(ServiceUnavailableError):
"""Raised when the fail2ban config directory is missing or inaccessible."""
error_code: str = "config_dir_unavailable"
class ConfigFileNotFoundError(NotFoundError):
"""Raised when a requested config file does not exist."""
error_code: str = "config_file_not_found"
def __init__(self, filename: str) -> None:
"""Initialize with the filename that was not found.
@@ -111,10 +164,15 @@ class ConfigFileNotFoundError(NotFoundError):
self.filename = filename
super().__init__(f"Config file not found: {filename!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"filename": self.filename}
class ConfigFileExistsError(ConflictError):
"""Raised when trying to create a file that already exists."""
error_code: str = "config_file_exists"
def __init__(self, filename: str) -> None:
"""Initialize with the filename that already exists.
@@ -124,22 +182,33 @@ class ConfigFileExistsError(ConflictError):
self.filename = filename
super().__init__(f"Config file already exists: {filename!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"filename": self.filename}
class ConfigFileWriteError(OperationError):
"""Raised when a file cannot be written (permissions, disk full, etc.)."""
error_code: str = "config_file_write_failed"
class ConfigFileNameError(BadRequestError):
"""Raised when a supplied filename is invalid or unsafe."""
error_code: str = "config_file_name_invalid"
class ServerOperationError(BadRequestError):
"""Raised when a server control command (e.g. refresh) fails."""
error_code: str = "server_operation_failed"
class Fail2BanConnectionError(ServiceUnavailableError):
"""Raised when the fail2ban socket is unreachable or returns an error."""
error_code: str = "fail2ban_unreachable"
def __init__(self, message: str, socket_path: str) -> None:
"""Initialize with a human-readable message and the socket path.
@@ -150,112 +219,215 @@ class Fail2BanConnectionError(ServiceUnavailableError):
self.socket_path: str = socket_path
super().__init__(f"{message} (socket: {socket_path})")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"socket_path": self.socket_path}
class Fail2BanProtocolError(ServiceUnavailableError):
"""Raised when the response from fail2ban cannot be parsed."""
error_code: str = "fail2ban_protocol_error"
class FilterInvalidRegexError(BadRequestError):
"""Raised when a regex pattern fails to compile."""
error_code: str = "filter_invalid_regex"
def __init__(self, pattern: str, error: str) -> None:
"""Initialize with the invalid pattern and compile error."""
self.pattern = pattern
self.error = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"pattern": self.pattern, "error": self.error}
class JailNotFoundInConfigError(NotFoundError):
"""Raised when the requested jail name is not defined in any config file."""
error_code: str = "jail_not_in_config"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found in config: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"jail_name": self.name}
class ConfigWriteError(OperationError):
"""Raised when writing a configuration file modification fails."""
error_code: str = "config_write_failed"
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"message": self.message}
class JailNameError(BadRequestError):
"""Raised when a jail name contains invalid characters."""
error_code: str = "jail_name_invalid"
class JailAlreadyActiveError(ConflictError):
"""Raised when trying to activate a jail that is already active."""
error_code: str = "jail_already_active"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail is already active: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"jail_name": self.name}
class JailAlreadyInactiveError(ConflictError):
"""Raised when trying to deactivate a jail that is already inactive."""
error_code: str = "jail_already_inactive"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail is already inactive: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"jail_name": self.name}
class FilterNotFoundError(NotFoundError):
"""Raised when the requested filter name is not found."""
error_code: str = "filter_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter not found: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"filter_name": self.name}
class FilterAlreadyExistsError(ConflictError):
"""Raised when trying to create a filter whose `.conf` or `.local` already exists."""
error_code: str = "filter_already_exists"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter already exists: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"filter_name": self.name}
class FilterNameError(BadRequestError):
"""Raised when a filter name contains invalid characters."""
error_code: str = "filter_name_invalid"
class FilterReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
error_code: str = "filter_readonly"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"filter_name": self.name}
class ActionNotFoundError(NotFoundError):
"""Raised when the requested action name is not found."""
error_code: str = "action_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action not found: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"action_name": self.name}
class ActionAlreadyExistsError(ConflictError):
"""Raised when trying to create an action whose `.conf` or `.local` already exists."""
error_code: str = "action_already_exists"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action already exists: {name!r}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"action_name": self.name}
class ActionNameError(BadRequestError):
"""Raised when an action name contains invalid characters."""
error_code: str = "action_name_invalid"
class ActionReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` action with no `.local` override."""
error_code: str = "action_readonly"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"action_name": self.name}
class SetupAlreadyCompleteError(ConflictError):
"""Raised when attempting to run setup when it has already been completed."""
error_code: str = "setup_already_complete"
def __init__(self) -> None:
super().__init__("Setup has already been completed.")
class BlocklistSourceNotFoundError(NotFoundError):
"""Raised when a blocklist source is not found."""
error_code: str = "blocklist_source_not_found"
def __init__(self, source_id: int) -> None:
self.source_id = source_id
super().__init__(f"Blocklist source not found: {source_id}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"source_id": self.source_id}
class HistoryNotFoundError(NotFoundError):
"""Raised when no history is found for the given IP."""
error_code: str = "history_not_found"
def __init__(self, ip: str) -> None:
self.ip = ip
super().__init__(f"No history found for IP: {ip}")
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"ip": self.ip}

View File

@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from starlette.responses import Response as StarletteResponse
import structlog
from fastapi import FastAPI, Request, status
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -30,12 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__
from app.config import Settings, get_settings
from app.exceptions import (
AuthenticationError,
BadRequestError,
ConflictError,
Fail2BanConnectionError,
Fail2BanProtocolError,
NotFoundError,
OperationError,
RateLimitError,
ServiceUnavailableError,
)
from app.middleware.csrf import CsrfMiddleware
@@ -54,6 +56,7 @@ from app.routers import (
setup,
)
from app.startup import startup_shared_resources
from app.models.response import ErrorResponse
from app.utils.rate_limiter import RateLimiter
from app.utils.runtime_state import ApplicationState, RuntimeState
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache
@@ -163,6 +166,43 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# ---------------------------------------------------------------------------
def _get_error_code(exc: Exception) -> str:
"""Get the machine-readable error code from an exception.
First checks if the exception has an error_code class attribute.
Falls back to converting the exception class name to snake_case.
Args:
exc: The exception instance.
Returns:
A snake_case error code string.
"""
if hasattr(exc, "error_code"):
return exc.error_code
exc_name = exc.__class__.__name__
import re
snake_case = re.sub(r"(?<!^)(?=[A-Z])", "_", exc_name).lower()
return snake_case
def _get_error_metadata(exc: Exception) -> dict[str, str | int | float | bool | None]:
"""Get structured metadata from an exception.
Calls the exception's get_error_metadata() method if available.
Args:
exc: The exception instance.
Returns:
A dictionary of metadata safe for API responses.
"""
if hasattr(exc, "get_error_metadata") and callable(exc.get_error_metadata):
return exc.get_error_metadata()
return {}
async def _unhandled_exception_handler(
request: Request,
exc: Exception,
@@ -185,9 +225,14 @@ async def _unhandled_exception_handler(
method=request.method,
exc_info=exc,
)
error_response = ErrorResponse(
code="internal_error",
detail="An unexpected error occurred. Please try again later.",
metadata={},
)
return JSONResponse(
status_code=500,
content={"detail": "An unexpected error occurred. Please try again later."},
content=error_response.model_dump(),
)
@@ -210,9 +255,14 @@ async def _fail2ban_connection_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="fail2ban_unreachable",
detail="Cannot reach the fail2ban service. Check the server status page.",
metadata={"socket_path": exc.socket_path},
)
return JSONResponse(
status_code=502,
content={"detail": "Cannot reach the fail2ban service. Check the server status page."},
content=error_response.model_dump(),
)
@@ -235,9 +285,14 @@ async def _fail2ban_protocol_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="fail2ban_protocol_error",
detail="Cannot reach the fail2ban service. Check the server status page.",
metadata={},
)
return JSONResponse(
status_code=502,
content={"detail": "Cannot reach the fail2ban service. Check the server status page."},
content=error_response.model_dump(),
)
@@ -260,9 +315,14 @@ async def _not_found_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -285,9 +345,14 @@ async def _bad_request_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -302,9 +367,14 @@ async def _conflict_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -320,9 +390,14 @@ async def _domain_error_handler(
error=str(exc),
exc_info=exc,
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -345,9 +420,14 @@ async def _value_error_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code="invalid_input",
detail=str(exc),
metadata={},
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
@@ -370,9 +450,126 @@ async def _service_unavailable_handler(
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"detail": str(exc)},
content=error_response.model_dump(),
)
async def _authentication_error_handler(
request: Request,
exc: AuthenticationError,
) -> JSONResponse:
"""Return a ``401 Unauthorized`` response for authentication failures.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.exceptions.AuthenticationError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 401.
"""
log.warning(
"authentication_error",
path=request.url.path,
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content=error_response.model_dump(),
)
async def _rate_limit_error_handler(
request: Request,
exc: RateLimitError,
) -> JSONResponse:
"""Return a ``429 Too Many Requests`` response for rate limit exceeded errors.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.exceptions.RateLimitError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 429 and Retry-After header.
"""
log.warning(
"rate_limit_exceeded",
path=request.url.path,
method=request.method,
error=str(exc),
)
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content=error_response.model_dump(),
headers={"Retry-After": "60"},
)
async def _http_exception_handler(
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""Return a standardized error response for FastAPI HTTPException.
This handler standardizes responses from FastAPI validation errors,
path parameter mismatches, and other built-in validation failures
to use the ErrorResponse envelope with a machine-readable error code.
Args:
request: The incoming FastAPI request.
exc: The :class:`fastapi.HTTPException`.
Returns:
A :class:`fastapi.responses.JSONResponse` with the original status code.
"""
log.warning(
"http_exception",
path=request.url.path,
method=request.method,
status_code=exc.status_code,
error=exc.detail,
)
error_code_map = {
status.HTTP_400_BAD_REQUEST: "invalid_input",
status.HTTP_401_UNAUTHORIZED: "authentication_required",
status.HTTP_403_FORBIDDEN: "forbidden",
status.HTTP_404_NOT_FOUND: "not_found",
status.HTTP_409_CONFLICT: "conflict",
status.HTTP_422_UNPROCESSABLE_ENTITY: "invalid_input",
status.HTTP_429_TOO_MANY_REQUESTS: "rate_limit_exceeded",
status.HTTP_500_INTERNAL_SERVER_ERROR: "internal_error",
status.HTTP_503_SERVICE_UNAVAILABLE: "service_unavailable",
}
error_code = error_code_map.get(exc.status_code, "internal_error")
error_response = ErrorResponse(
code=error_code,
detail=exc.detail,
metadata={},
)
return JSONResponse(
status_code=exc.status_code,
content=error_response.model_dump(),
headers=exc.headers or {},
)
@@ -509,11 +706,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# rather than falling through to the generic 500 handler.
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
app.add_exception_handler(AuthenticationError, _authentication_error_handler) # type: ignore[arg-type]
app.add_exception_handler(RateLimitError, _rate_limit_error_handler) # type: ignore[arg-type]
app.add_exception_handler(NotFoundError, _not_found_handler)
app.add_exception_handler(BadRequestError, _bad_request_handler)
app.add_exception_handler(ConflictError, _conflict_handler)
app.add_exception_handler(OperationError, _domain_error_handler)
app.add_exception_handler(ServiceUnavailableError, _service_unavailable_handler)
app.add_exception_handler(HTTPException, _http_exception_handler) # type: ignore[arg-type]
app.add_exception_handler(ValueError, _value_error_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, _unhandled_exception_handler)

View File

@@ -205,3 +205,48 @@ class CommandResponse(BanGuiBaseModel):
default=True,
description="Whether the command succeeded (false for errors in non-exception handlers).",
)
class ErrorResponse(BanGuiBaseModel):
"""Standardized error response envelope for all API errors.
Use this for all error responses to ensure consistent client-side error handling.
The error code enables machine-readable branching, while detail provides
human-readable context. Metadata offers optional structured context.
Fields:
code: Machine-readable error code (e.g., "jail_not_found", "invalid_input").
detail: Human-readable error description for display to users.
metadata: Optional structured context (e.g., field names, constraint violations).
Example:
```python
# 404 Not Found
{
"code": "jail_not_found",
"detail": "Jail 'sshd' not found",
"metadata": {"jail_name": "sshd"}
}
# 400 Bad Request - Validation Error
{
"code": "invalid_input",
"detail": "Invalid IP address format",
"metadata": {"field": "ip", "value": "999.999.999.999"}
}
# 409 Conflict
{
"code": "jail_already_active",
"detail": "Jail is already active: 'sshd'",
"metadata": {"jail_name": "sshd", "current_status": "active"}
}
```
"""
code: str = Field(..., description="Machine-readable error code for client-side branching.")
detail: str = Field(..., description="Human-readable error description.")
metadata: dict[str, str | int | float | bool | None] = Field(
default_factory=dict,
description="Optional structured context for the error.",
)

View File

@@ -22,7 +22,7 @@ from __future__ import annotations
import asyncio
import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi import APIRouter, Request, Response, status
from app.dependencies import (
AuthDep,
@@ -31,6 +31,7 @@ from app.dependencies import (
SessionServiceContextDep,
SettingsDep,
)
from app.exceptions import AuthenticationError, RateLimitError
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.services import auth_service
from app.utils.client_ip import get_client_ip
@@ -79,18 +80,14 @@ async def login(
:class:`~app.models.auth.LoginResponse` containing the token.
Raises:
HTTPException: 401 if the password is incorrect.
HTTPException: 429 if the rate limit is exceeded.
AuthenticationError: if the password is incorrect.
RateLimitError: if the rate limit is exceeded.
"""
client_ip = get_client_ip(request, trusted_proxies=_TRUSTED_PROXIES)
if not rate_limiter.is_allowed(client_ip):
log.warning("login_rate_limit_exceeded", client_ip=client_ip)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Please try again later.",
headers={"Retry-After": "60"},
)
raise RateLimitError("Too many login attempts. Please try again later.")
try:
signed_token, expires_at = await auth_service.login(
@@ -106,10 +103,7 @@ async def login(
# but an extra 10 seconds makes automation much less feasible.
await asyncio.sleep(10.0)
log.warning("login_failed", client_ip=client_ip, error=str(exc))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
raise AuthenticationError(str(exc)) from exc
response.set_cookie(
key=SESSION_COOKIE_NAME,

View File

@@ -22,7 +22,7 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Query, status
from app.dependencies import (
AuthDep,
@@ -33,6 +33,7 @@ from app.dependencies import (
SchedulerDep,
SettingsDep,
)
from app.exceptions import BadRequestError, BlocklistSourceNotFoundError
from app.models.blocklist import (
BlocklistListResponse,
BlocklistSource,
@@ -107,7 +108,7 @@ async def create_blocklist(
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise BadRequestError(str(exc)) from exc
# ---------------------------------------------------------------------------
@@ -271,7 +272,7 @@ async def get_blocklist(
"""
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
return source
@@ -307,9 +308,9 @@ async def update_blocklist(
enabled=payload.enabled,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
raise BadRequestError(str(exc)) from exc
if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
return updated
@@ -335,7 +336,7 @@ async def delete_blocklist(
"""
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
@router.get(
@@ -366,12 +367,9 @@ async def preview_blocklist(
"""
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
try:
return await blocklist_service.preview_source(source.url, http_session)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Could not fetch blocklist: {exc}",
) from exc
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc

View File

@@ -4,7 +4,7 @@ import shlex
from typing import Annotated
import structlog
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi import APIRouter, Query, Request, status
from app.dependencies import (
AuthDep,
@@ -12,6 +12,7 @@ from app.dependencies import (
Fail2BanStartCommandDep,
SettingsServiceContextDep,
)
from app.exceptions import OperationError
from app.models.config import (
Fail2BanLogResponse,
GlobalConfigResponse,
@@ -158,15 +159,12 @@ async def restart_fail2ban(
)
if not restarted:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"fail2ban was stopped but did not come back "
"online within 10 seconds. "
"Check the fail2ban log for initialisation errors. "
"Use POST /api/config/jails/{name}/rollback if a "
"specific jail is suspect."
),
raise OperationError(
"fail2ban was stopped but did not come back "
"online within 10 seconds. "
"Check the fail2ban log for initialisation errors. "
"Use POST /api/config/jails/{name}/rollback if a "
"specific jail is suspect."
)
log.info("fail2ban_restarted")

View File

@@ -6,7 +6,7 @@ state so monitoring tools and Docker health checks can observe daemon status
without probing the socket directly.
"""
from fastapi import APIRouter
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
from app.dependencies import ServerStatusDep

View File

@@ -17,7 +17,7 @@ from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi import APIRouter, Query, Request
from app.dependencies import (
AuthDep,
@@ -26,6 +26,7 @@ from app.dependencies import (
HistoryServiceContextDep,
HttpSessionDep,
)
from app.exceptions import HistoryNotFoundError
from app.models.ban import BanOrigin, TimeRange
from app.models.history import HistoryListResponse, IpDetailResponse
from app.services import history_service
@@ -188,6 +189,6 @@ async def get_ip_history(
)
if detail is None:
raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.")
raise HistoryNotFoundError(ip)
return detail

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import shlex
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from fastapi import APIRouter, Path, Query, Request, status
from app.dependencies import (
AppDep,
@@ -14,6 +14,7 @@ from app.dependencies import (
HealthProbeDep,
PendingRecoveryDep,
)
from app.exceptions import BadRequestError
from app.models.config import (
ActivateJailRequest,
AddLogPathRequest,
@@ -258,10 +259,7 @@ async def delete_log_path(
try:
validate_log_path(log_path)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e),
) from e
raise BadRequestError(str(e)) from e
await config_service.delete_log_path(socket_path, name, log_path)

View File

@@ -22,7 +22,7 @@ from __future__ import annotations
import asyncio
from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Path, status
from fastapi import APIRouter, Body, Path, status
from app.dependencies import (
AuthDep,
@@ -32,6 +32,7 @@ from app.dependencies import (
HttpSessionDep,
JailServiceStateDep,
)
from app.exceptions import BadRequestError
from app.models.ban import JailBannedIpsResponse
from app.models.jail import (
IgnoreIpRequest,
@@ -469,15 +470,9 @@ async def get_jail_banned_ips(
HTTPException: 502 when fail2ban is unreachable.
"""
if page < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page must be >= 1.",
)
raise BadRequestError("page must be >= 1.")
if not (1 <= page_size <= 100):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page_size must be between 1 and 100.",
)
raise BadRequestError("page_size must be between 1 and 100.")
return await jail_service.get_jail_banned_ips(
socket_path=socket_path,

View File

@@ -8,9 +8,10 @@ return ``409 Conflict``.
from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, status
from app.dependencies import AppDep, SettingsDep, SettingsServiceContextDep
from app.exceptions import SetupAlreadyCompleteError
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse, SetupTimezoneResponse
from app.services import setup_service
from app.utils.runtime_state import update_app_settings
@@ -59,13 +60,10 @@ async def post_setup(
:class:`~app.models.setup.SetupResponse` on success.
Raises:
HTTPException: 409 if setup has already been completed.
SetupAlreadyCompleteError: if setup has already been completed.
"""
if is_setup_complete_cached(app) or await setup_service.is_setup_complete(settings_ctx.db):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Setup has already been completed.",
)
raise SetupAlreadyCompleteError()
await setup_service.run_setup(
settings_ctx.db,