## 27) Error response body shape is inconsistent
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user