Error Handling¶
All API errors follow a consistent JSON format. Every error response contains a unique error ID, a machine-readable error code, a human-readable message, and a details array for field-specific validation errors.
Error Structure¶
{
"error_id": "err_f47ac10b-58cc-4372-a567-0e02b2c3d479",
"error_code": "ENTITY_NOT_FOUND",
"message": "PlantInstance with key 'pi_xyz' not found.",
"details": [
{
"field": "key",
"reason": "No PlantInstance with key 'pi_xyz'.",
"code": "ENTITY_NOT_FOUND",
"entity": "plant_instance"
}
],
"timestamp": "2026-03-17T10:30:00.000000+00:00",
"path": "/api/v1/plant-instances/pi_xyz",
"method": "GET"
}
| Field | Type | Description |
|---|---|---|
error_id | String | Unique UUID of the error event — use for support requests |
error_code | String | Machine-readable code (full list below) |
message | String | Short error description in English |
details | Array | Field-specific error details (may be empty) |
details[].field | String | Field name or path to the invalid value |
details[].reason | String | Explanation of the specific problem |
details[].code | String | Machine-readable detail code |
details[].entity | String | Optional. Name of the missing resource in snake_case, drawn from a closed vocabulary (task, attachment, plant_instance) — set on 404 errors only |
timestamp | String | Time of the error (ISO 8601, UTC) |
path | String | URL path of the failed request |
method | String | HTTP method of the failed request |
Telling two 404s on one route apart
A route that resolves a parent and then a child answers ENTITY_NOT_FOUND with HTTP 404 in both cases. What separates them is details[0].entity — not message, which is English prose and free to be reworded. A client that does not know the field behaves exactly as before: error_code and status are unchanged.
entity is a closed vocabulary
The value always names one kind of resource: one name per model, always singular (tenant, never tenants; plant_instance, never plant_instances). It is never a storage/collection name and never free text. Besides the model names there are named exceptions for things that are not models — among them storage_object (a file in object storage), session (a login session), mcp_tool and resource (the fallback used when the server must not name the kind). Compare the value exactly, and treat an unknown value as "not stated".
Use error_id for support
The error_id is logged on the server. Always include this ID when debugging or raising support requests — it allows the error to be traced precisely on the server side.
HTTP Status Codes¶
| Code | Meaning | When |
|---|---|---|
200 OK | Success | Read requests (GET) |
201 Created | Resource created | POST with new resource |
204 No Content | Success without body | DELETE |
400 Bad Request | Malformed request | Syntax error in request body |
401 Unauthorized | Not authenticated | Missing or invalid token |
403 Forbidden | No permission | Authenticated but missing role |
404 Not Found | Resource not found | Unknown ID or slug |
409 Conflict | Conflict | Duplicate entry or invalid state transition |
422 Unprocessable Entity | Validation error | Business rules violated (e.g. pre-harvest interval) |
423 Locked | Account locked | Too many failed login attempts |
429 Too Many Requests | Rate limit exceeded | Too many requests per minute |
502 Bad Gateway | External source unreachable | GBIF/Perenual timeout |
503 Service Unavailable | Feature not configured | Instance is missing configuration required for the action |
500 Internal Server Error | Internal error | Unexpected server error |
Error Codes¶
General Errors¶
| Error Code | HTTP | Description |
|---|---|---|
ENTITY_NOT_FOUND | 404 | The requested resource does not exist |
DUPLICATE_ENTRY | 409 | An entry with this value already exists |
VALIDATION_ERROR | 422 | Pydantic validation error (types, required fields) |
INTERNAL_ERROR | 500 | Unexpected internal error |
Authentication and Authorization Errors¶
| Error Code | HTTP | Description |
|---|---|---|
UNAUTHORIZED | 401 | No valid token present |
INVALID_TOKEN | 401 | Token expired or tampered |
FORBIDDEN | 403 | Authenticated but without sufficient role |
EMAIL_NOT_VERIFIED | 403 | Email address not yet confirmed |
ACCOUNT_LOCKED | 423 | Account locked after too many failed attempts |
STEP_UP_LOCKED | 429 | Too many failed confirmations on an irreversible account action or sign-in credential change (account or tenant deletion, email or password change, issuing an API key, pairing a device, removing a sign-in method, admin trust changes) — details[0].retry_after_minutes states the wait time; does not affect signing in |
STEP_UP_REAUTH_REQUIRED | 401 (422 when requesting the code) | An account with an OIDC-capable provider (Google, generic OIDC) must sign in again at that provider for this action — POST /users/me/step-up/oidc starts the sign-in; the emailed code is refused for this account |
STEP_UP_REAUTH_FAILED | 401 | The fresh sign-in at the provider did not confirm this action (expired, cancelled, or invalid) — only a redirect error code on /auth/step-up/callback, never JSON |
STEP_UP_PASSWORD_REQUIRED | 422 | The account has a local password and confirms with it — neither a fresh sign-in nor an emailed code is needed. |
STEP_UP_REAUTH_UNAVAILABLE | 422 | None of the account's linked providers (or the chosen one) can prove a fresh sign-in — the account confirms with the emailed code. |
STEP_UP_CODE_REQUIRED | 401 | An account without a local password and without an OIDC-capable provider (GitHub/Apple only) sent no confirmation code for one of these actions — request one first with POST /users/me/step-up-code |
STEP_UP_CODE_UNDELIVERABLE | 503 | The confirmation code could not be delivered by email (no mail delivery configured, or a mail server failure); nothing was issued — the cooldown and hourly budget are unaffected, so a retry can succeed as soon as the operator fixes delivery |
Phase and State Errors¶
| Error Code | HTTP | Description |
|---|---|---|
PHASE_TRANSITION_INVALID | 422 | Invalid phase status transition (e.g. backward transition) |
INVALID_STATUS_TRANSITION | 422 | Invalid status change of an object |
INVALID_RUN_STATE | 409 | Operation not allowed in the current state |
Plant Protection and Harvest Errors¶
| Error Code | HTTP | Description |
|---|---|---|
KARENZ_VIOLATION | 422 | Harvest not possible — pre-harvest interval (PHI) not yet elapsed |
RESISTANCE_WARNING | 422 | Resistance risk — active ingredient applied too many times consecutively |
HST_VIOLATION | 422 | Training measure (HST) not permitted in this growth phase |
Substrate and Compatibility Errors¶
| Error Code | HTTP | Description |
|---|---|---|
SUBSTRATE_EXHAUSTED | 422 | Substrate batch has exceeded maximum reuse cycles |
ROTATION_VIOLATION | 422 | Plant family was grown in this slot too recently |
INCOMPATIBLE_COMPANION | 422 | Two plant species are incompatible as companion plants |
External Services¶
| Error Code | HTTP | Description |
|---|---|---|
EXTERNAL_SOURCE_ERROR | 502 | External data service (GBIF, Perenual) not reachable |
ADAPTER_NOT_FOUND | 404 | No adapter registered for the specified source |
RATE_LIMIT_EXCEEDED | 429 | Rate limit of the server or an external service exceeded |
GDPR and Erasure Errors¶
| Error Code | HTTP | Description |
|---|---|---|
ERASURE_INCOMPLETE | 500 | The cleanup of never-confirmed accounts (or a synchronous operator run) did not reach a declared step; the erasure request is recorded and retried automatically. DELETE /admin/platform/users/{key} no longer answers it (202 since #1949): the same condition is the request status partially_completed at GET /admin/platform/erasures/{erasure_key} |
TENANT_ERASURE_INCOMPLETE | 500 | A tenant deletion did not reach something that still holds the tenant (the message names only collection names, never a tenant or account key); the deletion is recorded and retried automatically. Since issue #1792 the tenant-deletion endpoints (202 Accepted) no longer return this error to the caller — it occurs in the worker and is recorded on the deletion record |
WRITE_CONFLICT | 409 | An erasure for this account or tenant is already running |
FEATURE_NOT_CONFIGURED | 503 | The instance is not configured correctly for account or tenant erasure; nothing was changed |
Validation Errors (422)¶
FastAPI and Pydantic automatically produce structured validation errors for input mistakes. The details list contains one entry per invalid field:
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "not-a-valid-email",
"password": "short",
"display_name": ""
}
Response (422):
{
"error_id": "err_b5e3c8a1-...",
"error_code": "VALIDATION_ERROR",
"message": "The input data is invalid.",
"details": [
{
"field": "body.email",
"reason": "value is not a valid email address",
"code": "value_error"
},
{
"field": "body.password",
"reason": "String should have at least 10 characters",
"code": "string_too_short"
},
{
"field": "body.display_name",
"reason": "String should have at least 1 character",
"code": "string_too_short"
}
],
"timestamp": "2026-03-17T10:30:00.000000+00:00",
"path": "/api/v1/auth/register",
"method": "POST"
}
Pre-Harvest Interval Violation (KARENZ_VIOLATION)¶
The pre-harvest interval (PHI) gate prevents creating a harvest record while the waiting period of an applied pesticide has not yet elapsed.
Response (422) when PHI is active:
{
"error_id": "err_c7d2e5f3-...",
"error_code": "KARENZ_VIOLATION",
"message": "Cannot harvest: safety interval for 'Pyrethrin' has 5 days remaining.",
"details": [
{
"field": "active_ingredient",
"reason": "Safety interval not elapsed: 5 days remaining.",
"code": "KARENZ_VIOLATION"
}
],
"timestamp": "2026-03-17T10:30:00.000000+00:00",
"path": "/api/v1/t/my-garden/harvest/batches",
"method": "POST"
}
Pre-harvest intervals are legally relevant
The pre-harvest interval (Karenzzeit) between the last pesticide application and harvest is legally mandated by the PflSchG (German Plant Protection Act) and CanG (German Cannabis Act). Clients must never suppress or ignore this error.
Conflicts (409)¶
HTTP 409 is used when an operation cannot be performed due to the current state — for example, adding a plant to an already completed run:
{
"error_id": "err_a1b2c3d4-...",
"error_code": "INVALID_RUN_STATE",
"message": "Operation 'add_plant' not allowed in status 'completed'.",
"details": [],
"timestamp": "2026-03-17T10:30:00.000000+00:00",
"path": "/api/v1/t/my-garden/planting-runs/run_123/plants",
"method": "POST"
}
Rate Limiting (429)¶
When the rate limit is exceeded, the API responds with HTTP 429. The Retry-After header indicates how many seconds to wait before retrying:
Internal Errors (500)¶
For unexpected internal errors, the API never exposes server-side details. The error_id is the only instrument for error correlation:
{
"error_id": "err_9f8e7d6c-...",
"error_code": "INTERNAL_ERROR",
"message": "An internal error occurred. Please contact support with the reference ID.",
"details": [],
"timestamp": "2026-03-17T10:30:00.000000+00:00",
"path": "/api/v1/t/my-garden/plant-instances/",
"method": "GET"
}
Recommendations for API Clients¶
- Evaluate the error code, not just the HTTP status code. Multiple semantically distinct errors can share the same status code. For example,
KARENZ_VIOLATION,HST_VIOLATION, andROTATION_VIOLATIONare all HTTP 422. - Log the
error_id. Always include theerror_idin your own logs and in support requests. - Iterate the
detailsarray for field-specific error messages in form validation. - Respond to
401with a token refresh. When the access token expires (15 min), the API returns401 UNAUTHORIZED. The client should automatically attempt a refresh. - Do not retry
409 INVALID_RUN_STATE. This error indicates a business-level state conflict. The operation is only possible after a state change.
See Also¶
- API Overview — URL structure and endpoint groups
- Authentication — Auth errors in detail
- IPM / Pest Management — Pre-harvest interval concept explained