Errors & Status Codes
Every response that is not a success carries a structured JSON body so that clients can
branch on error.code and surface a useful message without parsing free text.
Error response shape
Section titled “Error response shape”Most error responses follow one canonical envelope:
{ "error": { "code": 403, "message": "Permission denied: requires 'device:write'", "request_id": "ext-4a3b2c1d-..." }}| Field | Type | Notes |
|---|---|---|
error.code | integer | Mirrors the HTTP status code |
error.message | string | Human-readable; safe to surface in UI |
error.request_id | string | Correlation ID; include this in support requests |
Validation errors (422) extend the shape with a details array so you can map errors
back to specific fields:
{ "error": { "code": 422, "message": "Validation error", "request_id": "ext-...", "details": [ { "field": "per_page", "message": "Input should be less than or equal to 100", "type": "less_than_equal" }, { "field": "site_id", "message": "Input should be a valid UUID", "type": "uuid_parsing" } ] }}The request_id header is also returned as X-Request-ID on every response, including
successes. When you supply your own X-Request-ID, the server prefixes it with ext-
so that client-supplied IDs are distinguishable in logs.
Common status codes
Section titled “Common status codes”400 Bad Request
Section titled “400 Bad Request”The server understood the request but cannot process it as written.
Common causes:
- Unknown or disallowed role -
validate_role_assignmentreturns 400 when the target role string is not recognised (e.g. a typo). Valid roles aresuper_admin,admin,org_admin,site_admin,operator,viewer,guest. - MFA-enabled account via OAuth2 password grant -
POST /api/v1/auth/tokenreturns 400 (not 401) for accounts with MFA enabled. UsePOST /api/v1/auth/loginfollowed byPOST /api/v1/auth/login/mfainstead. - Invalid path parameter - e.g. a
site_idpath segment that cannot be parsed as a UUID returns 400 from therequire_site_permissionsdependency.
401 Unauthorized
Section titled “401 Unauthorized”Authentication is missing or invalid. The response always includes:
WWW-Authenticate: BearerCauses and remedies:
| Cause | Detail |
|---|---|
| No credential supplied | Include Authorization: Bearer <token> or X-API-Key: <key> |
| Token expired | Exchange the refresh token at POST /api/v1/auth/refresh |
| Token version mismatch | The user’s password, role, or MFA status changed; re-login |
| Access JTI revoked | Session was terminated via logout or DELETE /api/v1/auth/sessions/{id} |
| API key deleted or expired | Check GET /api/v1/api-keys/ for active keys |
| Refresh token race | Two concurrent refreshes; only one wins. Re-login |
403 Forbidden
Section titled “403 Forbidden”The credential is valid but the action is not allowed for this principal.
Common causes:
| Situation | Notes |
|---|---|
| Missing permission | error.message names the required permission string (e.g. device:write) |
| CSRF token missing or mismatched | Mutations from a browser session must send the freesdn_csrf cookie value as X-CSRF-Token |
| Registration disabled | POST /auth/register returns 403 when ALLOW_REGISTRATION=false (the default) |
| Role assignment above caller’s own level | A caller cannot assign a role equal to or higher than their own |
| Scoped API key ceiling | Even a super_admin-owned key is limited to its declared scopes once scoped=true |
| Inactive user | get_current_active_user returns 403 (not 401) for users where is_active=false |
Permission strings follow two syntaxes used interchangeably:
- Colon -
device:read,network:*,controller:write - Dot -
cameras.view,firewall.manage_rules,voip.manage_phones
Wildcard matching is supported: device:* covers device:read, device:write, etc.
super_admin holds ["*"] and bypasses all permission checks unless the credential
is a scoped API key, in which case only the declared scopes apply.
404 Not Found
Section titled “404 Not Found”The resource does not exist within the caller’s organisation or site scope. FreeSDN uses 404 for cross-tenant isolation: a resource that exists in another tenant returns 404, not 403, so the existence of that resource is not disclosed.
| Situation | Notes |
|---|---|
| Record not found | Standard not-found |
| Cross-tenant access | A pending change, device, or site that belongs to a different org |
| Adapter resource missing | AdapterNotFoundError - the vendor controller returned no match |
| Revoked session not owned by caller | DELETE /auth/sessions/{session_id} returns 404 if the session does not belong to the caller |
409 Conflict
Section titled “409 Conflict”A uniqueness or limit constraint was violated.
| Endpoint area | Cause |
|---|---|
POST /api/v1/api-keys/ | Caller already holds 50 active API keys (MAX_KEYS_PER_USER) |
| Site creation | Site slug is not unique within the organisation |
| Concurrent resource creation | Row-level locking (e.g. SELECT ... FOR UPDATE on the user row) detected a race |
413 Request Entity Too Large
Section titled “413 Request Entity Too Large”The request body exceeds the hard 1 MiB (1,048,576 bytes) limit enforced by
BodySizeLimitMiddleware before the body reaches any handler. This limit exists as a
DoS backstop for free-form stage payload fields.
Remedy: split bulk payloads or use the dedicated bulk-operation endpoints where available.
422 Unprocessable Entity
Section titled “422 Unprocessable Entity”Pydantic v2 validation failed. Check error.details[] for the specific field and
constraint. Every field, query param, and path variable is validated before the handler
runs.
Common field-level causes:
- UUID fields that receive a non-UUID string
- Integer fields outside their declared range (e.g.
per_page> 100 or 200 depending on the endpoint) - String fields that exceed their max length (e.g. API key scope strings > 100 chars, description > 2000 chars)
- Missing required body fields on POST/PUT/PATCH
HTTP 423
Section titled “HTTP 423”Returned by POST /api/v1/auth/login and POST /api/v1/auth/token when an account is
temporarily locked after 5 consecutive failed login attempts. The lockout lasts 30 minutes.
429 Too Many Requests
Section titled “429 Too Many Requests”Two independent rate-limiting layers can return 429.
Application-level (sliding window)
Section titled “Application-level (sliding window)”The RateLimitMiddleware runs a per-user Redis sliding window. Defaults:
| Bucket | Limit | Retry-After |
|---|---|---|
| Burst | 120 requests / second | Retry-After: 1 |
| Sustained | 600 requests / minute | Retry-After: 60 |
Auth endpoints have a much tighter limit: 5 attempts per 60-second window per
source IP. This is a hardcoded security constant (_AUTH_RATE_LIMIT = 5 in
auth.py) enforced via Redis INCR+EXPIRE and is not tunable via environment
variable. Setting RATE_LIMIT_AUTH in the environment has no effect because the
config variable is declared but never consumed; the limit is wired to the
hardcoded constant _AUTH_RATE_LIMIT = 5 in auth.py. The env var will be
accepted without error but silently ignored. The response includes
X-RateLimit-Limit and X-RateLimit-Remaining headers.
Rate-limit keys are per authenticated user when a valid JWT is present; otherwise per source IP.
Discovery cap
Section titled “Discovery cap”POST /api/v1/discovery/scan returns 429 when the server-wide concurrent scan limit
(_MAX_ACTIVE_SCANS = 20) is reached across all organisations. Cancel or wait for any
active scan (not necessarily yours) to complete before starting a new one.
Adapter rate limit
Section titled “Adapter rate limit”AdapterRateLimitError from the upstream vendor controller also surfaces as 429 with
error.type: adapter_rate_limit.
501 Not Implemented
Section titled “501 Not Implemented”Certain capabilities are present in the API surface but are not available in this release.
| Endpoint area | Reason |
|---|---|
SAML SSO callback (/auth/sso/saml/callback) | Not available in this release; the assertion-acceptance step always returns 501. The initiation endpoint (/auth/sso/saml/login) is operational and returns the IdP redirect URL. |
MFA enrolment (/auth/mfa/setup, /auth/mfa/enable) | Returns 501 if the pyotp package is absent from the runtime. /auth/mfa/disable does not require pyotp and is always functional. |
| Access control door lock/unlock | No door adapter ships for any vendor yet |
503 Service Unavailable
Section titled “503 Service Unavailable”Returned in two distinct situations:
- Auth rate limiter fail-closed - Redis/Valkey is down and the request targets
an
/api/v1/auth/*path. See the 429 section above. - Adapter error - see the 502 section below; occasionally 503 is returned by the vendor controller itself and propagated.
502 / 504 (Adapter errors)
Section titled “502 / 504 (Adapter errors)”When the API successfully processes a request but the upstream vendor controller fails,
the error is translated to a structured response with an error.type field:
error.type | HTTP | Meaning |
|---|---|---|
adapter_connection_error | 502 | Could not connect to the vendor controller |
adapter_authentication_error | 502 | Credentials for the controller were rejected |
adapter_timeout | 504 | Vendor controller did not respond in time |
adapter_error | 502 | Generic adapter failure (check error.message) |
adapter_not_found | 404 | The resource does not exist on the vendor controller |
adapter_rate_limit | 429 | The vendor controller is rate-limiting this client |
These errors originate in the adapter layer, not in FreeSDN’s own database. The vendor controller may be unreachable, rebooting, or handling a configuration error. Check controller connectivity from the dashboard before assuming an API bug.
The 307 trailing-slash situation
Section titled “The 307 trailing-slash situation”FastAPI’s default behaviour is to issue a 307 Temporary Redirect when a URL without
a trailing slash matches a route registered with one (and vice versa). FreeSDN disables
this at the framework level (redirect_slashes=False) and handles trailing slashes
transparently in TrailingSlashNormalizeMiddleware instead.
What this means for you:
- Both
/api/v1/sitesand/api/v1/sites/reach the same handler with no redirect. - You will not receive a 307 from the API under normal operation.
The 307 can still appear from your own proxy layer. Always test with the exact URL path your client sends, not a normalised one.
Security headers on every response
Section titled “Security headers on every response”Error responses carry the same security headers as successful responses:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
X-XSS-Protection | 1; mode=block |
Referrer-Policy | strict-origin-when-cross-origin |
Cache-Control | no-store, no-cache, must-revalidate |
Pragma | no-cache |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() |
Content-Security-Policy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' |
X-Request-ID | Correlation ID for this request |
X-Response-Time | Server processing time in milliseconds |
Strict-Transport-Security is added when the request arrives over HTTPS.
Handling errors in practice
Section titled “Handling errors in practice”- Always branch on
error.code, not on the HTTP status code string or message text. Message text is for humans and may change between releases. - Log
error.request_idalongside your own trace ID so you can correlate with server logs when filing a support request. - On 401, attempt a token refresh via
POST /api/v1/auth/refreshbefore forcing a re-login. If the refresh itself returns 401, the session is gone - redirect to login. - On 429, respect the
Retry-Afterheader (1 second for burst, 60 seconds for sustained). Implement exponential back-off with jitter if you are running a bulk operation. - On 422, iterate
error.details[]to surface per-field messages rather than displaying the raw body. - On 502/504, check whether the vendor controller is reachable. These errors are not caused by FreeSDN itself and retrying immediately is unlikely to help.
- On 503 from an auth endpoint, your Valkey/Redis instance may be down. Check the
GET /healthendpoint (no auth required) for subsystem status.
Health and readiness endpoints
Section titled “Health and readiness endpoints”These endpoints are not authenticated and do not return the error envelope - they
are probed by container orchestration.
| Endpoint | Purpose |
|---|---|
GET /health | Returns {"status": "healthy"|"degraded", ...} with a degraded_subsystems list |
GET /api/v1/health | Public status-only check (no auth) |
GET /api/v1/health/detail | Full detail with latencies and versions (requires settings:read) |
GET /api/v1/health/live | Liveness probe |
GET /api/v1/health/ready | Readiness probe (gated by READINESS_STRICT_DEPS) |
Use /health (the top-level route, no auth) for container orchestration probes. Use
/api/v1/health/detail (requires settings:read) for full latency and version detail
when debugging a startup failure.
Next steps
Section titled “Next steps”- Authentication - how tokens, cookies, and API keys work
- API Keys - scoped keys and their permission ceiling
- Pagination & Filtering - query parameters for list endpoints
- API Overview - base URL, versioning, and content types