Files
unshackle/docs/dev/rest-api/errors.md
T

255 lines
12 KiB
Markdown

# Error Responses
Every endpoint of the unshackle REST API reports failures in a single, predictable
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
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
code the server can send.
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.
!!! note "Where this comes from"
`unshackle/core/api/errors.py` defines the error format, and the wrapper
that turns raised errors into responses lives in `unshackle/core/api/routes.py`.
Nothing on this page is invented. It reflects the actual server behaviour.
## 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.
- A "title not found" error on a remote session includes `available_titles`.
- 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
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.
!!! 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. |
| `INVALID_PROXY` | 400 | The supplied proxy specification could not be resolved or is malformed. |
| `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). |
| `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. |
| `GEOFENCE` | 403 | The title is not available in the applicable region. |
| `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. |
| `TRACK_NOT_FOUND` | 404 | A referenced track ID is not present in the remote session. |
| `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. |
### 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.
## How the server produces errors
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:
| If the error mentions... | It becomes | Retryable |
|---|---|---|
| `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 |
| `geofence`, `region`, `not available in`, `territory` | `GEOFENCE` (403) | no |
| `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 |
| 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
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
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"
A client must expect two error formats: the standard structured error
(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.
## 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"
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.
## Debug information
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
`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
captured job worker stderr.
!!! 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:
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
body.
2. **Branch on `error_code`.** For structured errors, this is the stable identifier
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.
!!! note "Reference consumer"
unshackle's own remote-download client treats any status of `400` or above as
fatal: it logs `Server error [<error_code>]: <message>` and exits. It also wraps
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.