Skip to main content

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"
}
}
FieldTypeDescription
statusu16HTTP status code
error.typestringError type identifier
error.messagestringHuman-readable error message
error.detailsobject | nullAdditional details (type-specific, see below)
error.pathstringRequest path (currently empty)
error.timestampstringISO 8601 timestamp of the error
_links.selfstringSelf link (currently empty)
_links.docsstringLink to error documentation

Error Types and Status Codes

TypeStatus CodeDescription
BadRequest400Validation error, invalid input, business rule violation
Unauthorized401Missing or invalid API key
Forbidden403Valid credentials but insufficient permissions
NotFound404Resource not found
Conflict409Resource conflict (e.g., duplicate)
RateLimitExceeded429Too many requests
Internal500Unexpected server error
Database503Database 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 FieldTypeDescription
retryAfteru64Seconds until the window resets
limitu64Maximum requests per window
currentu64Current 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 FieldTypeDescription
constraintstringDatabase 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" }
}

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.