Error Handling
The Apollon API uses a structured error response format defined in the apollon-common crate via the ApiError type (built with thiserror).
Error Response Format
All API errors follow this JSON structure:
{
"status": 404,
"error": {
"type": "NotFound",
"message": "Trip 550e8400-e29b-41d4-a716-446655440000 not found",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": {
"self": "",
"docs": "/docs/errors/notfound"
}
}
| Field | Type | Description |
|---|---|---|
status | u16 | HTTP status code |
error.type | string | Error type identifier |
error.message | string | Human-readable error message |
error.details | object | null | Additional details (type-specific, see below) |
error.path | string | Request path (currently empty) |
error.timestamp | string | ISO 8601 timestamp of the error |
_links.self | string | Self link (currently empty) |
_links.docs | string | Link to error documentation |
Error Types and Status Codes
| Type | Status Code | Description |
|---|---|---|
BadRequest | 400 | Validation error, invalid input, business rule violation |
Unauthorized | 401 | Missing or invalid API key |
Forbidden | 403 | Valid credentials but insufficient permissions |
NotFound | 404 | Resource not found |
Conflict | 409 | Resource conflict (e.g., duplicate) |
RateLimitExceeded | 429 | Too many requests |
Internal | 500 | Unexpected server error |
Database | 503 | Database connectivity or query error |
Error Details
Most error types have null details. The following types include additional information:
RateLimitExceeded (429)
{
"status": 429,
"error": {
"type": "RateLimitExceeded",
"message": "Rate limit exceeded",
"details": {
"retryAfter": 30,
"limit": 100,
"current": 101
},
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": {
"self": "",
"docs": "/docs/errors/ratelimitexceeded"
}
}
| Detail Field | Type | Description |
|---|---|---|
retryAfter | u64 | Seconds until the window resets |
limit | u64 | Maximum requests per window |
current | u64 | Current request count |
Database (503)
{
"status": 503,
"error": {
"type": "Database",
"message": "Duplicate entry",
"details": {
"constraint": "uq_categories_name"
},
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": {
"self": "",
"docs": "/docs/errors/database"
}
}
| Detail Field | Type | Description |
|---|---|---|
constraint | string | Database constraint name (when available) |
Validation Error Examples
Invalid Coordinates
POST /v1/trips
{"startLatitude": 95.0, "startLongitude": -0.1278, ...}
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "Latitude must be between -90 and 90, got 95",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
Empty Required Field
POST /v1/trips
{"startLatitude": 51.5, "startLongitude": -0.1, "cargo": "", ...}
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "cargo must not be empty",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
Foreign Key Not Found
POST /v1/trips
{"categoryId": "00000000-0000-0000-0000-000000000000", ...}
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "Category 00000000-0000-0000-0000-000000000000 not found",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
Invalid Status Transition
PUT /v1/trips/{id}
{"status": "ACTIVE"}
(When the trip is already COMPLETED)
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "Cannot transition from Completed to Active",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
Duplicate Category Name
POST /v1/categories
{"name": "Fragile Goods"}
(When a category with this name already exists)
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "A category with this name already exists",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
Category Still Referenced
DELETE /v1/categories/{id}
(When trips still reference this category)
{
"status": 400,
"error": {
"type": "BadRequest",
"message": "Cannot delete category: trips still reference it",
"details": null,
"path": "",
"timestamp": "2026-06-09T12:00:00Z"
},
"_links": { "self": "", "docs": "/docs/errors/badrequest" }
}
HATEOAS Links
Successful responses on GPS and CRUD endpoints include _links with a self reference:
{
"latitude": 51.5074,
"longitude": -0.1278,
"speed": 45.2,
"_links": {
"self": "/v1/peplink/gps"
}
}
For collection endpoints, _links.self points to the collection:
{
"trips": [ ... ],
"_links": {
"self": "/v1/trips"
}
}
For individual resources, _links.self points to the specific resource:
{
"id": "550e8400-...",
"cargo": "Electronics",
"_links": {
"self": "/v1/trips/550e8400-..."
}
}
The LinkedResponse wrapper from apollon-common automatically flattens the data alongside the _links field using serde's #[serde(flatten)].
Middleware Error Responses
Some errors are returned by middleware before reaching the handler. These have simpler formats:
API Key Auth (401)
{
"error": "Unauthorized",
"message": "A valid X-API-Key header is required"
}
Rate Limiter (429)
{
"error": "Too many requests",
"retryAfter": 45
}
These middleware error responses use a simplified two-field format rather than the full ErrorResponse structure, since they bypass the ApiError type.