## 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

@@ -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,