Skip to content

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.


Most error responses follow one canonical envelope:

{
"error": {
"code": 403,
"message": "Permission denied: requires 'device:write'",
"request_id": "ext-4a3b2c1d-..."
}
}
FieldTypeNotes
error.codeintegerMirrors the HTTP status code
error.messagestringHuman-readable; safe to surface in UI
error.request_idstringCorrelation 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.


The server understood the request but cannot process it as written.

Common causes:

  • Unknown or disallowed role - validate_role_assignment returns 400 when the target role string is not recognised (e.g. a typo). Valid roles are super_admin, admin, org_admin, site_admin, operator, viewer, guest.
  • MFA-enabled account via OAuth2 password grant - POST /api/v1/auth/token returns 400 (not 401) for accounts with MFA enabled. Use POST /api/v1/auth/login followed by POST /api/v1/auth/login/mfa instead.
  • Invalid path parameter - e.g. a site_id path segment that cannot be parsed as a UUID returns 400 from the require_site_permissions dependency.

Authentication is missing or invalid. The response always includes:

WWW-Authenticate: Bearer

Causes and remedies:

CauseDetail
No credential suppliedInclude Authorization: Bearer <token> or X-API-Key: <key>
Token expiredExchange the refresh token at POST /api/v1/auth/refresh
Token version mismatchThe user’s password, role, or MFA status changed; re-login
Access JTI revokedSession was terminated via logout or DELETE /api/v1/auth/sessions/{id}
API key deleted or expiredCheck GET /api/v1/api-keys/ for active keys
Refresh token raceTwo concurrent refreshes; only one wins. Re-login

The credential is valid but the action is not allowed for this principal.

Common causes:

SituationNotes
Missing permissionerror.message names the required permission string (e.g. device:write)
CSRF token missing or mismatchedMutations from a browser session must send the freesdn_csrf cookie value as X-CSRF-Token
Registration disabledPOST /auth/register returns 403 when ALLOW_REGISTRATION=false (the default)
Role assignment above caller’s own levelA caller cannot assign a role equal to or higher than their own
Scoped API key ceilingEven a super_admin-owned key is limited to its declared scopes once scoped=true
Inactive userget_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.

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.

SituationNotes
Record not foundStandard not-found
Cross-tenant accessA pending change, device, or site that belongs to a different org
Adapter resource missingAdapterNotFoundError - the vendor controller returned no match
Revoked session not owned by callerDELETE /auth/sessions/{session_id} returns 404 if the session does not belong to the caller

A uniqueness or limit constraint was violated.

Endpoint areaCause
POST /api/v1/api-keys/Caller already holds 50 active API keys (MAX_KEYS_PER_USER)
Site creationSite slug is not unique within the organisation
Concurrent resource creationRow-level locking (e.g. SELECT ... FOR UPDATE on the user row) detected a race

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.

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

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.

Two independent rate-limiting layers can return 429.

The RateLimitMiddleware runs a per-user Redis sliding window. Defaults:

BucketLimitRetry-After
Burst120 requests / secondRetry-After: 1
Sustained600 requests / minuteRetry-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.

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.

AdapterRateLimitError from the upstream vendor controller also surfaces as 429 with error.type: adapter_rate_limit.

Certain capabilities are present in the API surface but are not available in this release.

Endpoint areaReason
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/unlockNo door adapter ships for any vendor yet

Returned in two distinct situations:

  1. Auth rate limiter fail-closed - Redis/Valkey is down and the request targets an /api/v1/auth/* path. See the 429 section above.
  2. Adapter error - see the 502 section below; occasionally 503 is returned by the vendor controller itself and propagated.

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.typeHTTPMeaning
adapter_connection_error502Could not connect to the vendor controller
adapter_authentication_error502Credentials for the controller were rejected
adapter_timeout504Vendor controller did not respond in time
adapter_error502Generic adapter failure (check error.message)
adapter_not_found404The resource does not exist on the vendor controller
adapter_rate_limit429The 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.


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/sites and /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.


Error responses carry the same security headers as successful responses:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
X-XSS-Protection1; mode=block
Referrer-Policystrict-origin-when-cross-origin
Cache-Controlno-store, no-cache, must-revalidate
Pragmano-cache
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policydefault-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-IDCorrelation ID for this request
X-Response-TimeServer processing time in milliseconds

Strict-Transport-Security is added when the request arrives over HTTPS.


  1. 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.
  2. Log error.request_id alongside your own trace ID so you can correlate with server logs when filing a support request.
  3. On 401, attempt a token refresh via POST /api/v1/auth/refresh before forcing a re-login. If the refresh itself returns 401, the session is gone - redirect to login.
  4. On 429, respect the Retry-After header (1 second for burst, 60 seconds for sustained). Implement exponential back-off with jitter if you are running a bulk operation.
  5. On 422, iterate error.details[] to surface per-field messages rather than displaying the raw body.
  6. 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.
  7. On 503 from an auth endpoint, your Valkey/Redis instance may be down. Check the GET /health endpoint (no auth required) for subsystem status.

These endpoints are not authenticated and do not return the error envelope - they are probed by container orchestration.

EndpointPurpose
GET /healthReturns {"status": "healthy"|"degraded", ...} with a degraded_subsystems list
GET /api/v1/healthPublic status-only check (no auth)
GET /api/v1/health/detailFull detail with latencies and versions (requires settings:read)
GET /api/v1/health/liveLiveness probe
GET /api/v1/health/readyReadiness 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.