2026-07-03 19:24:11 -06:00
# Error Responses
Every endpoint of the unshackle REST API reports failures in a single, predictable
2026-08-19 16:31:06 -06:00
shape. The cause can be a missing field, a service that refuses to authenticate, or a
download worker that crashed. In each case the server answers with a JSON body
2026-07-03 19:24:11 -06:00
that carries a machine-readable **error code** , a human-readable **message** , and a
matching **HTTP status** . This page documents that format and catalogues every error
2026-08-19 16:31:06 -06:00
code the server can send.
2026-07-03 19:24:11 -06:00
2026-08-19 16:31:06 -06:00
If you write a client against the API, read this page alongside the endpoint
reference. Each endpoint lists the specific codes it raises, and this page gives the
meanings of those codes.
2026-07-03 19:24:11 -06:00
!!! note "Where this comes from"
2026-08-19 16:31:06 -06:00
`unshackle/core/api/errors.py` defines the error format, and the wrapper
2026-07-03 19:24:11 -06:00
that turns raised errors into responses lives in `unshackle/core/api/routes.py` .
2026-08-19 16:31:06 -06:00
Nothing on this page is invented. It reflects the actual server behaviour.
2026-07-03 19:24:11 -06:00
## The standard error shape
A structured error is always a JSON object with `status` set to the literal string
`"error"` . The full set of fields:
```json title="Error response body"
{
"status": "error",
"error_code": "INVALID_PARAMETERS",
"message": "vcodec must be one of: H264, H265, ...",
"timestamp": "2026-07-03T12:34:56.789012+00:00",
"details": { "field": "vcodec" },
"retryable": true,
"debug_info": {
"exception_type": "APIError",
"traceback": "..."
}
}
` ``
| Field | Type | Always present? | Meaning |
|---|---|---|---|
| ` status` | string | Yes | Always the literal ` "error"`. Lets a client distinguish an error body from a success body at a glance. |
| ` error_code` | string | Yes | A stable, machine-readable code from the [catalogue below](#error-code-catalogue). Match on this, not on ` message`. |
| ` message` | string | Yes | A human-readable description of what went wrong. Wording may change between versions, so do not parse it. |
| ` timestamp` | string | Yes | ISO 8601 timestamp (UTC) of when the error response was built. |
| ` details` | object | Only when non-empty | Structured, error-specific context (for example the invalid field, a list of available titles, or the set of currently active jobs). |
| ` retryable` | boolean | Only when ` true` | Present and set to ` true` when retrying the same request may succeed later (network blips, rate limiting, transient outages). Absent otherwise. |
| ` debug_info` | object | Only with ` --debug-api` | Technical diagnostics; see [Debug information](#debug-information). |
!!! tip "Match on ` error_code`, branch on HTTP status"
Treat ` error_code` as the primary signal your code reacts to, and the HTTP status
as a coarse fallback for anything you do not recognise. The two are consistent:
every code maps to a default status (see the catalogue), and the pairing is
stable across releases.
### The ` details` object
When present, ` details` carries extra context specific to the error. It is only
included when it is non-empty, so a client must treat a missing ` details` as "no
extra information". Some concrete examples you may encounter:
- A validation failure may include the offending field or the list of valid values.
2026-08-19 16:31:06 -06:00
- A "title not found" error on a remote session includes ` available_titles`.
2026-07-03 19:24:11 -06:00
- A maintenance conflict includes ` active_jobs`, the jobs blocking the operation.
- Categorised exceptions add a ` reason` tag, such as ` "network_connectivity"` or
` "geofence_restriction"`.
### The ` retryable` hint
The ` retryable` field appears **only when it is ` true`**. It is set for conditions
the server believes are transient: network errors, rate limiting, and temporary
2026-08-19 16:31:06 -06:00
service unavailability. When you see it, you can back off and try the request again.
When it is absent, assume the request will keep failing until you change something.
2026-07-03 19:24:11 -06:00
!!! example "A retryable network error"
` ``json
{
"status": "error",
"error_code": "NETWORK_ERROR",
"message": "Network error occurred: Connection timed out",
"timestamp": "2026-07-03T12:00:00+00:00",
"details": { "reason": "network_connectivity" },
"retryable": true
}
` ``
## Error code catalogue
Every code below is a member of the ` APIErrorCode` enumeration. The **Status** column
is the default HTTP status the server returns for that code. A handler can override
the status in special cases (for example, a network error is always forced to ` 503`),
but the defaults hold for the overwhelming majority of responses.
### Client errors (4xx)
| Code | Status | Meaning |
|---|---|---|
| ` INVALID_INPUT` | 400 | A required field is missing, or the request body could not be parsed. Also used as the generic "your input was malformed" code. |
| ` INVALID_SERVICE` | 400 | The named service is unknown, or is not permitted for your API key. |
2026-09-10 17:08:04 -06:00
| ` INVALID_PROXY` | 400 | The supplied proxy specification could not be resolved or is malformed, or the proxy failed the [exit check](../../guide/proxies-and-vpn.md#exit-check). |
2026-07-03 19:24:11 -06:00
| ` INVALID_PARAMETERS` | 400 | One or more download or query parameters failed validation (bad codec, bitrate, sort field, and so on). |
| ` AUTH_FAILED` | 401 | Authentication with the streaming service failed (bad credentials or cookies). |
2026-08-19 20:59:09 -06:00
| ` FORBIDDEN` | 403 | The action is not allowed. Raised by server-side gates (per-key restrictions on CDM or credential overrides) and by remote session IP binding. |
2026-09-25 12:32:49 -06:00
| ` SERVER_CDM_CAPPED` | 403 | The server CDM does not license live above the ` server_cdm_max_height` of the API key. ` details.reason` is ` server_cdm_max_height` (with ` details.max_height`), ` client_device`, for a remote session where the client's own device licenses, or ` server_device`, for a remote session that runs on the server's device. A remote client licenses the track with its own device, except for ` server_device`: then only its own vaults can supply the key. |
2026-08-19 20:59:09 -06:00
| ` GEOFENCE` | 403 | The title is not available in the applicable region. |
2026-07-03 19:24:11 -06:00
| ` NOT_FOUND` | 404 | A requested resource (title, history entry, and so on) does not exist. |
| ` NO_CONTENT` | 404 | The request was valid but produced nothing: no matching titles, tracks, episodes, or keys. |
| ` JOB_NOT_FOUND` | 404 | The referenced download job does not exist. |
| ` SESSION_NOT_FOUND` | 404 | The remote-dl session does not exist or has expired. |
2026-08-19 20:59:09 -06:00
| ` TRACK_NOT_FOUND` | 404 | A referenced track ID is not present in the remote session. |
2026-07-03 19:24:11 -06:00
| ` CONFLICT` | 409 | The target resource is in a state that disallows the action (retrying a running job, prioritising a non-queued job, clearing cache during an active download). |
| ` RATE_LIMITED` | 429 | The streaming service is rate-limiting requests. Retryable. |
2026-09-02 23:52:55 -06:00
!!! warning "Not every ` 429` is ` RATE_LIMITED`"
The server has a rate limit of its own. An API key over ` serve.tiers.<name>.rate_limit`
or ` serve.users.<key>.rate_limit` gets a ` 429` from the authentication middleware, before
any handler runs, so it carries no ` error_code` and is not the ` RATE_LIMITED` code above.
It is the shape ` {"status": 429, "message": "Rate limit exceeded."}` with a ` Retry-After`
header. Read ` Retry-After` and wait it out rather than retrying on a backoff of your own:
the window is fixed, so an early retry only spends the next window. See
[Authentication](authentication.md#rate-limit-rejections).
2026-07-03 19:24:11 -06:00
### Server errors (5xx)
| Code | Status | Meaning |
|---|---|---|
| ` INTERNAL_ERROR` | 500 | An unexpected server-side error. This is the fallback for any exception the server could not categorise. |
| ` DOWNLOAD_ERROR` | 500 | The download process itself failed. |
| ` WORKER_ERROR` | 500 | The download worker subprocess errored out. |
| ` SERVICE_ERROR` | 502 | The streaming service's API returned an error, or the service does not support the requested operation. |
| ` DRM_ERROR` | 502 | DRM or license acquisition failed. Retryable. |
| ` NETWORK_ERROR` | 503 | A network connectivity problem (connection, timeout, DNS, TLS). Retryable. |
| ` SERVICE_UNAVAILABLE` | 503 | The service is temporarily unavailable or under maintenance. Retryable. |
!!! note "Fallback status"
If a code is ever emitted without a mapped status, the server falls back to
` 500`. In practice every code above has an explicit mapping.
2026-08-19 16:31:06 -06:00
## How the server produces errors
2026-07-03 19:24:11 -06:00
Understanding where an error comes from helps interpret it.
**Explicitly raised errors.** Most endpoints raise a structured ` APIError` directly.
For example, a missing ` service` field yields ` INVALID_INPUT`, and a disallowed
service yields ` INVALID_SERVICE`. These carry a precise code, message, and often a
populated ` details` object.
**Categorised exceptions.** When an endpoint calls into a streaming service and an
uncategorised exception bubbles up, the server runs it through a classifier that
inspects the exception type and message and maps it to the most appropriate code.
The classification is keyword-based, checked in order:
2026-07-06 22:50:38 -06:00
| If the error mentions... | It becomes | Retryable |
2026-07-03 19:24:11 -06:00
|---|---|---|
2026-08-19 20:59:09 -06:00
| ` auth`, ` login`, ` credential`, ` unauthorized`, ` forbidden`, ` token` | ` AUTH_FAILED` (401) | no |
| ` connection`, ` timeout`, ` network`, ` unreachable`, ` socket`, ` dns`, ` resolve` (or ` ConnectionError`, ` TimeoutError`, ` URLError`, ` SSLError`) | ` NETWORK_ERROR` (503) | yes |
2026-09-16 15:06:43 -06:00
| ` geofence`, ` geoblock`, ` region`, ` not available in`, ` territory` | ` GEOFENCE` (403) | no |
2026-08-19 20:59:09 -06:00
| ` not found`, ` 404`, ` does not exist`, ` invalid id` | ` NOT_FOUND` (404) | no |
| ` rate limit`, ` too many requests`, ` 429`, ` throttle` | ` RATE_LIMITED` (429) | yes |
| ` drm`, ` license`, ` widevine`, ` playready`, ` decrypt` | ` DRM_ERROR` (502) | no |
| ` service unavailable`, ` 503`, ` maintenance`, ` temporarily unavailable` | ` SERVICE_UNAVAILABLE` (503) | yes |
| ` invalid`, ` malformed`, ` validation` (or ` ValueError`, ` ValidationError`) | ` INVALID_INPUT` (400) | no |
2026-07-03 19:24:11 -06:00
| anything else | ` INTERNAL_ERROR` (500) | no |
Categorised errors include a ` reason` tag inside ` details` (such as
` "network_connectivity"` or ` "drm_failure"`) so a client can react without parsing
the message text.
**Invalid JSON bodies.** If a ` POST` endpoint receives a body that is not valid JSON,
the server returns ` INVALID_INPUT` (400) with the parse error in
` details.error`:
` ``json title="Malformed request body"
{
"status": "error",
"error_code": "INVALID_INPUT",
"message": "Invalid JSON request body",
"timestamp": "2026-07-03T12:00:00+00:00",
"details": { "error": "Expecting value: line 1 column 1 (char 0)" }
}
` ``
## The authentication error exception
2026-08-19 16:31:06 -06:00
There is one place where the response does **not** follow the standard shape: the
API key authentication middleware. When you send a request to a protected endpoint
2026-07-03 19:24:11 -06:00
without a valid ` X-Secret-Key` header, the middleware short-circuits with a legacy
body that uses an integer ` status` and no ` error_code` or ` timestamp`.
=== "Missing key"
` ``json
{ "status": 401, "message": "Secret Key is Empty." }
` ``
=== "Invalid key"
` ``json
{ "status": 401, "message": "Secret Key is Invalid." }
` ``
!!! warning "Handle both shapes"
2026-08-19 16:31:06 -06:00
A client must expect two error formats: the standard structured error
2026-07-03 19:24:11 -06:00
(string ` status` of ` "error"`, with ` error_code`), and this authentication error
(integer ` status`, no ` error_code`). The ` GET /api/health` endpoint is exempt
from authentication, so it never returns the key-error shape.
2026-09-24 10:18:03 -06:00
## Late failures with status ` 200`
The remote session ` titles`, ` tracks` and ` license` routes send status ` 200` and the headers
when the work takes longer than 30 seconds. They then send a newline every 30 seconds to keep
the connection open. See [Slow responses](endpoints.md#slow-responses). A failure after that
time arrives as the standard error body, but with status ` 200`. The body has no field for the
HTTP status that the error would otherwise have. ` error_code` and ` retryable` stay in the body.
!!! warning "Check the body on these routes"
On these routes a ` 200` does not always mean success. Parse the body and treat
` "status": "error"` with an ` error_code` as a failure.
2026-07-03 19:24:11 -06:00
## Responses with no body
Not every failure or completion has a JSON body. Some endpoints answer with
**` 204 No Content`** and an empty body, notably deleting a terminal download job and
deleting a history entry.
!!! warning "Do not parse ` 204` bodies"
2026-08-19 16:31:06 -06:00
A ` 204` response has no body. Clients must examine the status code before they
decode JSON, or the decode will fail on an empty string.
2026-07-03 19:24:11 -06:00
## Debug information
2026-08-19 16:31:06 -06:00
By default, error responses never include tracebacks or internal diagnostics. If you
start the server with the ` --debug-api` flag, every structured error gains a
2026-07-03 19:24:11 -06:00
` debug_info` object:
` ``json title="Error with --debug-api enabled"
{
"status": "error",
"error_code": "INTERNAL_ERROR",
"message": "An unexpected error occurred: ...",
"timestamp": "2026-07-03T12:00:00+00:00",
"debug_info": {
"exception_type": "KeyError",
"traceback": "Traceback (most recent call last): ..."
}
}
` ``
The ` debug_info` object always carries ` exception_type` and, for exception-derived
errors, a formatted ` traceback`. Some endpoints add extra diagnostics such as
2026-08-19 16:31:06 -06:00
captured job worker stderr.
2026-07-03 19:24:11 -06:00
!!! warning "Developer use only"
` --debug-api` exposes internal tracebacks in responses. Enable it only for local
debugging, never on a server reachable by untrusted clients.
## Client handling recommendations
For anyone writing a consumer of the API, a durable strategy is:
2026-08-19 16:31:06 -06:00
1. **Read the HTTP status first.** It tells you the broad class of outcome, and it
lets you find the ` 204`/no-body and authentication-error cases before you read the
2026-07-03 19:24:11 -06:00
body.
2. **Branch on ` error_code`.** For structured errors, this is the stable identifier
2026-08-19 16:31:06 -06:00
to switch on. Do not use ` message` as the identifier.
3. **Respect ` retryable`.** When it is ` true`, back off and try the request again. The
reference client retries ` 429`, ` 500`, ` 502`, ` 503`, and ` 504` with exponential
backoff. When absent, fix the request rather than repeating it.
4. **Fall back gracefully.** For an unrecognised code, show the ` message` and the
HTTP status.
2026-07-03 19:24:11 -06:00
!!! note "Reference consumer"
unshackle's own remote-download client treats any status of ` 400` or above as
2026-09-24 10:18:03 -06:00
fatal, and a ` 200` body with ` "status": "error"` and an ` error_code` too: it logs
` Server error [<error_code>]: <message>` and exits. It also wraps
2026-07-03 19:24:11 -06:00
requests in a retry adapter (five attempts, backing off on ` 429`, ` 500`, ` 502`,
` 503`, and ` 504`). This is a reasonable template for your own client's error
handling.