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

@@ -521,6 +521,129 @@ total_pages = compute_total_pages(total, page_size)
--- ---
## 4.2 Error Response Schema
All error responses use a consistent machine-readable format that enables frontend code to branch reliably on error conditions without string-parsing error detail text.
### Error Response Format
Every non-2xx HTTP response body is a JSON object with this structure:
```json
{
"code": "jail_not_found",
"detail": "Jail 'example' not found",
"metadata": {
"jail_name": "example"
}
}
```
**Fields:**
- **`code`** (string, required): Machine-readable error code for client-side branching. Examples: `jail_not_found`, `rate_limit_exceeded`, `authentication_required`.
- **`detail`** (string, required): Human-readable error message. Safe for displaying to users.
- **`metadata`** (object, optional): Structured context data relevant to the error. Only includes data safe for client consumption (no sensitive internal state). Examples: offending parameter names, resource identifiers, time windows.
### Exception Hierarchy & Error Codes
All domain exceptions inherit from `DomainError` (defined in `backend/app/exceptions.py`) and are organized by HTTP status category:
| HTTP Status | Category Class | Error Codes | Use Case |
|---|---|---|---|
| **404** | `NotFoundError` | `not_found`, `jail_not_found`, `filter_not_found`, `action_not_found`, `config_file_not_found`, `blocklist_source_not_found`, `history_not_found` | Requested resource does not exist |
| **400** | `BadRequestError` | `invalid_input`, `config_validation_failed`, `config_operation_failed`, `jail_name_invalid`, `filter_name_invalid`, `action_name_invalid`, `config_file_name_invalid`, `filter_invalid_regex` | Invalid input, validation failure, malformed request |
| **409** | `ConflictError` | `conflict`, `jail_operation_failed`, `jail_already_active`, `jail_already_inactive`, `jail_not_in_config`, `action_already_exists`, `filter_already_exists`, `config_file_exists` | State conflict, resource already exists, invalid state transition |
| **500** | `OperationError` | `operation_failed`, `config_write_failed`, `config_file_write_failed`, `server_operation_failed`, `fail2ban_protocol_error` | Operation failure, write errors, unexpected failures |
| **503** | `ServiceUnavailableError` | `service_unavailable`, `config_dir_unavailable`, `fail2ban_unreachable` | Infrastructure/external service issues, temporary unavailability |
| **401** | `AuthenticationError` | `authentication_required` | Authentication or authorization failure, invalid/expired credentials |
| **429** | `RateLimitError` | `rate_limit_exceeded` | Rate limit exceeded, too many requests |
### Implementing Error Handlers
Every exception category has a corresponding exception handler registered in `backend/app/main.py`. When a domain exception is raised:
1. FastAPI's exception handling middleware catches it.
2. The registered handler converts it to an `ErrorResponse` with HTTP status code.
3. The response is serialized as JSON with `code`, `detail`, and `metadata` fields.
**Pattern for service code:**
```python
from app.exceptions import JailNotFoundError, ConfigValidationError
async def get_jail(name: str) -> Jail:
"""Raises JailNotFoundError if jail not found."""
jail = await db.fetchone("SELECT * FROM jails WHERE name = ?", (name,))
if jail is None:
raise JailNotFoundError(name) # HTTP 404, code='jail_not_found'
return jail
async def apply_config(config: JailConfig) -> None:
"""Raises ConfigValidationError if invalid."""
if not config.filter_name:
raise ConfigValidationError("filter_name is required") # HTTP 400, code='config_validation_failed'
```
### Adding New Exception Types
1. **Choose the appropriate category** based on the HTTP status (NotFoundError for 404, BadRequestError for 400, etc.).
2. **Create a subclass** in `backend/app/exceptions.py`:
```python
class MySpecificError(BadRequestError):
"""Raised when X happens."""
error_code: str = "my_specific_error"
def __init__(self, detail_msg: str, **context) -> None:
self.context = context
super().__init__(detail_msg)
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
"""Return only safe, relevant metadata."""
return {k: v for k, v in self.context.items() if k in ("offending_value", "constraint")}
```
3. **Use explicit error codes** — Don't derive them from the class name. This makes them self-documenting and prevents breakage on class renames.
4. **Implement `get_error_metadata()`** — Return only data safe for client consumption. Never leak internal state, file paths, or system details.
5. **Raise from service code** — Never from repositories or utils. Exceptions represent business logic violations, not infrastructure errors.
**What NOT to do:**
- ❌ Don't raise `HTTPException` from service code (bypass the ErrorResponse format).
- ❌ Don't put sensitive information in `metadata` (database paths, SQL, internal IDs).
- ❌ Don't derive error codes from class names using regex (fragile and non-self-documenting).
### Frontend Error Parsing
The frontend `ApiError` class parses error responses automatically:
```typescript
import { api } from "src/api/client";
try {
const jail = await api.get("/jails/example");
} catch (error) {
if (error instanceof ApiError) {
const code = error.errorResponse?.code;
if (code === "jail_not_found") {
// Handle not found
console.log("Jail does not exist:", error.errorResponse?.metadata?.jail_name);
} else if (code === "rate_limit_exceeded") {
// Handle rate limit
showRateLimitModal();
} else if (code === "authentication_required") {
// Handle auth — the frontend framework auto-redirects to /login
redirectToLogin();
}
}
}
```
The `errorResponse` field contains the parsed error object with `code`, `detail`, and `metadata` fields, enabling reliable machine-readable branching.
---
## 5. Pydantic Models ## 5. Pydantic Models
### Base Class ### Base Class

View File

@@ -1,24 +1,3 @@
## 26) Pagination contract is not standardized across endpoints
- Where found:
- [backend/app/routers/dashboard.py](backend/app/routers/dashboard.py)
- [backend/app/routers/history.py](backend/app/routers/history.py)
- [backend/app/routers/blocklist.py](backend/app/routers/blocklist.py)
- Why this is needed:
- Different pagination patterns increase frontend complexity.
- Goal:
- Use one shared paginated response model.
- What to do:
- Introduce common pagination schema and query parameter policy.
- Possible traps and issues:
- Existing endpoint consumers may require transition period.
- Docs changes needed:
- Add pagination API spec.
- Doc references:
- [Docs/Backend-Development.md](Docs/Backend-Development.md)
---
## 27) Error response body shape is inconsistent ## 27) Error response body shape is inconsistent
- Where found: - Where found:
- [backend/app/main.py](backend/app/main.py) - [backend/app/main.py](backend/app/main.py)

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 - **ConflictError** (409): State conflict, resource already exists, invalid state transition
- **OperationError** (500): Operation failure, write errors - **OperationError** (500): Operation failure, write errors
- **ServiceUnavailableError** (503): Infrastructure/external service issues - **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 Service exceptions inherit from the appropriate category, allowing routers to
handle categories rather than individual exception types. Exception handlers in handle categories rather than individual exception types. Exception handlers in
main.py register only base category types. 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: Example:
def get_jail(name: str) -> Jail: def get_jail(name: str) -> Jail:
# Raises JailNotFoundError (subclass of NotFoundError) # Raises JailNotFoundError (subclass of NotFoundError)
@@ -22,52 +28,84 @@ Example:
@app.exception_handler(NotFoundError) @app.exception_handler(NotFoundError)
async def handle_not_found(request, exc): 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. See Backend-Development.md for the complete exception contract.
""" """
from __future__ import annotations from __future__ import annotations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Exception Base Classes (Categories) # Exception Base Classes (Categories)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class DomainError(Exception): class DomainError(Exception):
"""Base class for all domain exceptions.""" """Base class for all domain exceptions.
pass 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
"""
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): class NotFoundError(DomainError):
"""Raised when a requested domain entity is not found. HTTP 404.""" """Raised when a requested domain entity is not found. HTTP 404."""
pass error_code: str = "not_found"
class BadRequestError(DomainError): class BadRequestError(DomainError):
"""Raised for invalid input, validation failures, or invalid identifiers. HTTP 400.""" """Raised for invalid input, validation failures, or invalid identifiers. HTTP 400."""
pass error_code: str = "invalid_input"
class ConflictError(DomainError): class ConflictError(DomainError):
"""Raised for state conflicts or resource constraints. HTTP 409.""" """Raised for state conflicts or resource constraints. HTTP 409."""
pass error_code: str = "conflict"
class OperationError(DomainError): class OperationError(DomainError):
"""Raised when a domain operation fails (write, update, etc.). HTTP 500.""" """Raised when a domain operation fails (write, update, etc.). HTTP 500."""
pass error_code: str = "operation_failed"
class ServiceUnavailableError(DomainError): class ServiceUnavailableError(DomainError):
"""Raised for infrastructure or external service issues. HTTP 503.""" """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): class JailNotFoundError(NotFoundError):
"""Raised when a requested jail name does not exist.""" """Raised when a requested jail name does not exist."""
error_code: str = "jail_not_found"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Jail not found: {name!r}") 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): class JailOperationError(ConflictError):
"""Raised when a jail state operation fails (e.g. start/stop already in progress).""" """Raised when a jail state operation fails (e.g. start/stop already in progress)."""
error_code: str = "jail_operation_failed"
class ConfigValidationError(BadRequestError): class ConfigValidationError(BadRequestError):
"""Raised when config values fail validation before applying.""" """Raised when config values fail validation before applying."""
error_code: str = "config_validation_failed"
class ConfigOperationError(BadRequestError): class ConfigOperationError(BadRequestError):
"""Raised when a config payload update or command fails.""" """Raised when a config payload update or command fails."""
error_code: str = "config_operation_failed"
class ConfigDirError(ServiceUnavailableError): class ConfigDirError(ServiceUnavailableError):
"""Raised when the fail2ban config directory is missing or inaccessible.""" """Raised when the fail2ban config directory is missing or inaccessible."""
error_code: str = "config_dir_unavailable"
class ConfigFileNotFoundError(NotFoundError): class ConfigFileNotFoundError(NotFoundError):
"""Raised when a requested config file does not exist.""" """Raised when a requested config file does not exist."""
error_code: str = "config_file_not_found"
def __init__(self, filename: str) -> None: def __init__(self, filename: str) -> None:
"""Initialize with the filename that was not found. """Initialize with the filename that was not found.
@@ -111,10 +164,15 @@ class ConfigFileNotFoundError(NotFoundError):
self.filename = filename self.filename = filename
super().__init__(f"Config file not found: {filename!r}") 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): class ConfigFileExistsError(ConflictError):
"""Raised when trying to create a file that already exists.""" """Raised when trying to create a file that already exists."""
error_code: str = "config_file_exists"
def __init__(self, filename: str) -> None: def __init__(self, filename: str) -> None:
"""Initialize with the filename that already exists. """Initialize with the filename that already exists.
@@ -124,22 +182,33 @@ class ConfigFileExistsError(ConflictError):
self.filename = filename self.filename = filename
super().__init__(f"Config file already exists: {filename!r}") 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): class ConfigFileWriteError(OperationError):
"""Raised when a file cannot be written (permissions, disk full, etc.).""" """Raised when a file cannot be written (permissions, disk full, etc.)."""
error_code: str = "config_file_write_failed"
class ConfigFileNameError(BadRequestError): class ConfigFileNameError(BadRequestError):
"""Raised when a supplied filename is invalid or unsafe.""" """Raised when a supplied filename is invalid or unsafe."""
error_code: str = "config_file_name_invalid"
class ServerOperationError(BadRequestError): class ServerOperationError(BadRequestError):
"""Raised when a server control command (e.g. refresh) fails.""" """Raised when a server control command (e.g. refresh) fails."""
error_code: str = "server_operation_failed"
class Fail2BanConnectionError(ServiceUnavailableError): class Fail2BanConnectionError(ServiceUnavailableError):
"""Raised when the fail2ban socket is unreachable or returns an error.""" """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: def __init__(self, message: str, socket_path: str) -> None:
"""Initialize with a human-readable message and the socket path. """Initialize with a human-readable message and the socket path.
@@ -150,112 +219,215 @@ class Fail2BanConnectionError(ServiceUnavailableError):
self.socket_path: str = socket_path self.socket_path: str = socket_path
super().__init__(f"{message} (socket: {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): class Fail2BanProtocolError(ServiceUnavailableError):
"""Raised when the response from fail2ban cannot be parsed.""" """Raised when the response from fail2ban cannot be parsed."""
error_code: str = "fail2ban_protocol_error"
class FilterInvalidRegexError(BadRequestError): class FilterInvalidRegexError(BadRequestError):
"""Raised when a regex pattern fails to compile.""" """Raised when a regex pattern fails to compile."""
error_code: str = "filter_invalid_regex"
def __init__(self, pattern: str, error: str) -> None: def __init__(self, pattern: str, error: str) -> None:
"""Initialize with the invalid pattern and compile error.""" """Initialize with the invalid pattern and compile error."""
self.pattern = pattern self.pattern = pattern
self.error = error self.error = error
super().__init__(f"Invalid regex {pattern!r}: {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): class JailNotFoundInConfigError(NotFoundError):
"""Raised when the requested jail name is not defined in any config file.""" """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: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Jail not found in config: {name!r}") 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): class ConfigWriteError(OperationError):
"""Raised when writing a configuration file modification fails.""" """Raised when writing a configuration file modification fails."""
error_code: str = "config_write_failed"
def __init__(self, message: str) -> None: def __init__(self, message: str) -> None:
self.message = message self.message = message
super().__init__(message) super().__init__(message)
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"message": self.message}
class JailNameError(BadRequestError): class JailNameError(BadRequestError):
"""Raised when a jail name contains invalid characters.""" """Raised when a jail name contains invalid characters."""
error_code: str = "jail_name_invalid"
class JailAlreadyActiveError(ConflictError): class JailAlreadyActiveError(ConflictError):
"""Raised when trying to activate a jail that is already active.""" """Raised when trying to activate a jail that is already active."""
error_code: str = "jail_already_active"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Jail is already active: {name!r}") 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): class JailAlreadyInactiveError(ConflictError):
"""Raised when trying to deactivate a jail that is already inactive.""" """Raised when trying to deactivate a jail that is already inactive."""
error_code: str = "jail_already_inactive"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Jail is already inactive: {name!r}") 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): class FilterNotFoundError(NotFoundError):
"""Raised when the requested filter name is not found.""" """Raised when the requested filter name is not found."""
error_code: str = "filter_not_found"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Filter not found: {name!r}") 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): class FilterAlreadyExistsError(ConflictError):
"""Raised when trying to create a filter whose `.conf` or `.local` already exists.""" """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: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Filter already exists: {name!r}") 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): class FilterNameError(BadRequestError):
"""Raised when a filter name contains invalid characters.""" """Raised when a filter name contains invalid characters."""
error_code: str = "filter_name_invalid"
class FilterReadonlyError(ConflictError): class FilterReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override.""" """Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
error_code: str = "filter_readonly"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__( super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted." 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): class ActionNotFoundError(NotFoundError):
"""Raised when the requested action name is not found.""" """Raised when the requested action name is not found."""
error_code: str = "action_not_found"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Action not found: {name!r}") 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): class ActionAlreadyExistsError(ConflictError):
"""Raised when trying to create an action whose `.conf` or `.local` already exists.""" """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: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__(f"Action already exists: {name!r}") 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): class ActionNameError(BadRequestError):
"""Raised when an action name contains invalid characters.""" """Raised when an action name contains invalid characters."""
error_code: str = "action_name_invalid"
class ActionReadonlyError(ConflictError): class ActionReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` action with no `.local` override.""" """Raised when trying to delete a shipped `.conf` action with no `.local` override."""
error_code: str = "action_readonly"
def __init__(self, name: str) -> None: def __init__(self, name: str) -> None:
self.name = name self.name = name
super().__init__( super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted." 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 from starlette.responses import Response as StarletteResponse
import structlog import structlog
from fastapi import FastAPI, Request, status from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -30,12 +30,14 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__ from app import __version__
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.exceptions import ( from app.exceptions import (
AuthenticationError,
BadRequestError, BadRequestError,
ConflictError, ConflictError,
Fail2BanConnectionError, Fail2BanConnectionError,
Fail2BanProtocolError, Fail2BanProtocolError,
NotFoundError, NotFoundError,
OperationError, OperationError,
RateLimitError,
ServiceUnavailableError, ServiceUnavailableError,
) )
from app.middleware.csrf import CsrfMiddleware from app.middleware.csrf import CsrfMiddleware
@@ -54,6 +56,7 @@ from app.routers import (
setup, setup,
) )
from app.startup import startup_shared_resources from app.startup import startup_shared_resources
from app.models.response import ErrorResponse
from app.utils.rate_limiter import RateLimiter from app.utils.rate_limiter import RateLimiter
from app.utils.runtime_state import ApplicationState, RuntimeState from app.utils.runtime_state import ApplicationState, RuntimeState
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache 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( async def _unhandled_exception_handler(
request: Request, request: Request,
exc: Exception, exc: Exception,
@@ -185,9 +225,14 @@ async def _unhandled_exception_handler(
method=request.method, method=request.method,
exc_info=exc, exc_info=exc,
) )
error_response = ErrorResponse(
code="internal_error",
detail="An unexpected error occurred. Please try again later.",
metadata={},
)
return JSONResponse( return JSONResponse(
status_code=500, 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, method=request.method,
error=str(exc), 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( return JSONResponse(
status_code=502, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code="fail2ban_protocol_error",
detail="Cannot reach the fail2ban service. Check the server status page.",
metadata={},
)
return JSONResponse( return JSONResponse(
status_code=502, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_409_CONFLICT, 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), error=str(exc),
exc_info=exc, exc_info=exc,
) )
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code="invalid_input",
detail=str(exc),
metadata={},
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, 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, method=request.method,
error=str(exc), error=str(exc),
) )
error_response = ErrorResponse(
code=_get_error_code(exc),
detail=str(exc),
metadata=_get_error_metadata(exc),
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 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. # 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(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_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(NotFoundError, _not_found_handler)
app.add_exception_handler(BadRequestError, _bad_request_handler) app.add_exception_handler(BadRequestError, _bad_request_handler)
app.add_exception_handler(ConflictError, _conflict_handler) app.add_exception_handler(ConflictError, _conflict_handler)
app.add_exception_handler(OperationError, _domain_error_handler) app.add_exception_handler(OperationError, _domain_error_handler)
app.add_exception_handler(ServiceUnavailableError, _service_unavailable_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(ValueError, _value_error_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, _unhandled_exception_handler) app.add_exception_handler(Exception, _unhandled_exception_handler)

View File

@@ -205,3 +205,48 @@ class CommandResponse(BanGuiBaseModel):
default=True, default=True,
description="Whether the command succeeded (false for errors in non-exception handlers).", 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 asyncio
import structlog import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, Request, Response, status
from app.dependencies import ( from app.dependencies import (
AuthDep, AuthDep,
@@ -31,6 +31,7 @@ from app.dependencies import (
SessionServiceContextDep, SessionServiceContextDep,
SettingsDep, SettingsDep,
) )
from app.exceptions import AuthenticationError, RateLimitError
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.services import auth_service from app.services import auth_service
from app.utils.client_ip import get_client_ip from app.utils.client_ip import get_client_ip
@@ -79,18 +80,14 @@ async def login(
:class:`~app.models.auth.LoginResponse` containing the token. :class:`~app.models.auth.LoginResponse` containing the token.
Raises: Raises:
HTTPException: 401 if the password is incorrect. AuthenticationError: if the password is incorrect.
HTTPException: 429 if the rate limit is exceeded. RateLimitError: if the rate limit is exceeded.
""" """
client_ip = get_client_ip(request, trusted_proxies=_TRUSTED_PROXIES) client_ip = get_client_ip(request, trusted_proxies=_TRUSTED_PROXIES)
if not rate_limiter.is_allowed(client_ip): if not rate_limiter.is_allowed(client_ip):
log.warning("login_rate_limit_exceeded", client_ip=client_ip) log.warning("login_rate_limit_exceeded", client_ip=client_ip)
raise HTTPException( raise RateLimitError("Too many login attempts. Please try again later.")
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Please try again later.",
headers={"Retry-After": "60"},
)
try: try:
signed_token, expires_at = await auth_service.login( 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. # but an extra 10 seconds makes automation much less feasible.
await asyncio.sleep(10.0) await asyncio.sleep(10.0)
log.warning("login_failed", client_ip=client_ip, error=str(exc)) log.warning("login_failed", client_ip=client_ip, error=str(exc))
raise HTTPException( raise AuthenticationError(str(exc)) from exc
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
response.set_cookie( response.set_cookie(
key=SESSION_COOKIE_NAME, 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 __future__ import annotations
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, Query, status
from app.dependencies import ( from app.dependencies import (
AuthDep, AuthDep,
@@ -33,6 +33,7 @@ from app.dependencies import (
SchedulerDep, SchedulerDep,
SettingsDep, SettingsDep,
) )
from app.exceptions import BadRequestError, BlocklistSourceNotFoundError
from app.models.blocklist import ( from app.models.blocklist import (
BlocklistListResponse, BlocklistListResponse,
BlocklistSource, BlocklistSource,
@@ -107,7 +108,7 @@ async def create_blocklist(
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
) )
except ValueError as exc: 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) source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None: if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") raise BlocklistSourceNotFoundError(source_id)
return source return source
@@ -307,9 +308,9 @@ async def update_blocklist(
enabled=payload.enabled, enabled=payload.enabled,
) )
except ValueError as exc: 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: if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") raise BlocklistSourceNotFoundError(source_id)
return updated return updated
@@ -335,7 +336,7 @@ async def delete_blocklist(
""" """
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id) deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") raise BlocklistSourceNotFoundError(source_id)
@router.get( @router.get(
@@ -366,12 +367,9 @@ async def preview_blocklist(
""" """
source = await blocklist_service.get_source(blocklist_ctx.db, source_id) source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None: if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") raise BlocklistSourceNotFoundError(source_id)
try: try:
return await blocklist_service.preview_source(source.url, http_session) return await blocklist_service.preview_source(source.url, http_session)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Could not fetch blocklist: {exc}",
) from exc

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
* to guarantee type safety at the API boundary. * to guarantee type safety at the API boundary.
*/ */
import { ErrorResponse } from "../types/response";
import { ENDPOINTS } from "./endpoints"; import { ENDPOINTS } from "./endpoints";
/** Base URL for all API calls. Falls back to `/api` in production. */ /** Base URL for all API calls. Falls back to `/api` in production. */
@@ -27,15 +28,20 @@ export class ApiError extends Error {
/** Raw response body text as returned by the server. */ /** Raw response body text as returned by the server. */
public readonly body: string; public readonly body: string;
/** Parsed error response (if response was a valid ErrorResponse), undefined otherwise. */
public readonly errorResponse: ErrorResponse | undefined;
/** /**
* @param status - The HTTP status code. * @param status - The HTTP status code.
* @param body - The raw response body text. * @param body - The raw response body text.
* @param errorResponse - Parsed ErrorResponse if available.
*/ */
constructor(status: number, body: string) { constructor(status: number, body: string, errorResponse?: ErrorResponse) {
super(`API error ${String(status)}: ${body}`); super(`API error ${String(status)}: ${errorResponse?.detail || body}`);
this.name = "ApiError"; this.name = "ApiError";
this.status = status; this.status = status;
this.body = body; this.body = body;
this.errorResponse = errorResponse;
} }
} }
@@ -79,7 +85,7 @@ export function setUnauthorizedHandler(handler: (() => void) | null): void {
* @param url - Fully-qualified URL. * @param url - Fully-qualified URL.
* @param options - Standard `RequestInit` options. * @param options - Standard `RequestInit` options.
* @returns Parsed JSON response cast to `T`. * @returns Parsed JSON response cast to `T`.
* @throws {FetchError} When the request fails or server returns non-2xx status. * @throws {ApiError} When the request fails or server returns non-2xx status.
*/ */
async function request<T>(url: string, options: RequestInit = {}): Promise<T> { async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
try { try {
@@ -100,7 +106,18 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
unauthorizedHandler?.(); unauthorizedHandler?.();
} }
throw new ApiError(response.status, body); // Try to parse as ErrorResponse
let errorResponse: ErrorResponse | undefined;
try {
const parsed = JSON.parse(body);
if (parsed && typeof parsed === "object" && "code" in parsed && "detail" in parsed) {
errorResponse = parsed as ErrorResponse;
}
} catch {
// If parsing fails, errorResponse remains undefined
}
throw new ApiError(response.status, body, errorResponse);
} }
// 204 No Content — return undefined cast to T. // 204 No Content — return undefined cast to T.

View File

@@ -19,6 +19,12 @@ export interface ApiErrorPayload {
body: string; body: string;
/** User-friendly error message derived from status and body. */ /** User-friendly error message derived from status and body. */
message: string; message: string;
/** Machine-readable error code for client-side branching (e.g., "jail_not_found"). */
code?: string;
/** Human-readable error description from the server. */
detail?: string;
/** Optional structured context for the error (e.g., field names, constraint violations). */
metadata?: Record<string, string | number | boolean | null>;
} }
/** /**

View File

@@ -25,3 +25,12 @@ export interface CommandResponse {
/** Whether the command succeeded. */ /** Whether the command succeeded. */
success: boolean; success: boolean;
} }
export interface ErrorResponse {
/** Machine-readable error code for client-side branching (e.g., "jail_not_found", "rate_limit_exceeded"). */
code: string;
/** Human-readable error description for display to users. */
detail: string;
/** Optional structured context for the error (field names, constraint violations, etc.). */
metadata: Record<string, string | number | boolean | null | undefined>;
}