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