mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
feat(remote): keep slow remote session requests alive with a heartbeat
Titles, tracks and licence stream a newline every 30s until the body is ready, so a slow service outlasts the client timeout and proxy idle limits; add a per-server timeout.
This commit is contained in:
@@ -15,7 +15,7 @@ Start the server with [`unshackle serve`](../../guide/cli-reference.md). By defa
|
||||
- **Authentication.** Every request except `GET /api/health` requires the `X-Secret-Key` header when you configure an API key. `--no-key` disables the check entirely. See [Authentication](authentication.md).
|
||||
- **Content type.** Request bodies are JSON (`Content-Type: application/json`). All success responses are JSON unless this page documents a `204 No Content`.
|
||||
- **CORS.** Every response carries permissive CORS headers, and the server answers `OPTIONS` preflight requests automatically.
|
||||
- **Compression.** JSON responses of 256 bytes or more are gzip-compressed when the client sends `Accept-Encoding: gzip`.
|
||||
- **Compression.** JSON responses of 256 bytes or more are gzip-compressed when the client sends `Accept-Encoding: gzip`. A [slow response](#slow-responses) is not compressed.
|
||||
- **Service allowlist.** The effective allowlist for your API key filters the service-facing endpoints (the intersection of the global `serve.services` list and your per-key list). The server treats services you cannot use as unknown.
|
||||
|
||||
!!! warning "Error responses have two shapes"
|
||||
@@ -883,6 +883,22 @@ POST /api/session/{id}/keys/bad → report a server-vault content key that did n
|
||||
DELETE /api/session/{id} → tear down, harvest updated cache
|
||||
```
|
||||
|
||||
### Slow responses
|
||||
|
||||
The `titles`, `tracks` and `license` routes can take minutes on a large catalogue. When the
|
||||
answer is not ready after 30 seconds, the route sends status `200` and the headers. It then
|
||||
sends a newline every 30 seconds until the JSON body is ready. JSON parsers ignore the leading
|
||||
newlines. This keeps the connection open through a reverse proxy that closes an idle request.
|
||||
|
||||
!!! warning "A late failure has status `200`"
|
||||
The server sent the status before the work ended. A failure after the first 30 seconds
|
||||
arrives as the [standard error body](errors.md#the-standard-error-shape) with status `200`.
|
||||
Check `status` in the body, not only the HTTP status. The body has no HTTP status field.
|
||||
Use `error_code` and `retryable` to decide what to do.
|
||||
|
||||
A slow response is not gzip-compressed. Gzip output cannot start before the body is ready, and
|
||||
the newlines must go out at once.
|
||||
|
||||
### `POST /api/session/create`
|
||||
|
||||
Make a remote session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with `status: "authenticating"`, and you then poll the prompt endpoint. The body accepts `service` and `title_id` (both required). It also accepts a broad set of optional keys, because the body allows `additionalProperties`. These are `credentials` (`{username, password, extra?}`), `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`, `no_proxy`, `profile`, `cache` (map of forwarded cache files, keyed by the file path relative to the service cache directory with `/` separators and no `.json` suffix, so a nested key such as `session_web/<sha1>` lands in a subdirectory), `client_region`, `proxy_region`, `cdm_type`, `range_`, `vcodec`, `quality`, `best_available`, `dl_params`, `client`, and any service CLI options. `proxy_region` is the country code the client resolved `proxy` from; the server matches it against its own accounts. `client` is a freeform object the dashboard shows as sent (the CLI puts `version`, `code_hash`, `platform` and a redacted `argv` in it); the server ignores it above 4096 bytes of JSON.
|
||||
@@ -989,6 +1005,7 @@ The buffer keeps the newest 500 records. Only the service instance's own logger
|
||||
### `GET /api/session/{session_id}/titles`
|
||||
|
||||
Fetch the titles for an authenticated remote session.
|
||||
A slow answer arrives as a [slow response](#slow-responses).
|
||||
|
||||
=== "Response `200`"
|
||||
|
||||
@@ -1003,7 +1020,7 @@ Requires an authenticated remote session. Otherwise it returns `404 SESSION_NOT_
|
||||
|
||||
### `POST /api/session/{session_id}/tracks`
|
||||
|
||||
Get tracks and chapters for a specific title within the remote session. Body: `{ "title_id": "..." }` (required). Unlike `list-tracks`, track objects here **include** download URLs, along with session headers/cookies and manifest data the client needs to download directly.
|
||||
Get tracks and chapters for a specific title within the remote session. Body: `{ "title_id": "..." }` (required). Unlike `list-tracks`, track objects here **include** download URLs, along with session headers/cookies and manifest data the client needs to download directly. A slow answer arrives as a [slow response](#slow-responses).
|
||||
|
||||
`manifests` holds each served DASH/ISM manifest once. A service can build an AdaptationSet of its own that the served manifest does not contain. `track_manifests` holds a one-AdaptationSet MPD for each such track, and the client re-parses that in place of the whole manifest.
|
||||
|
||||
@@ -1071,6 +1088,7 @@ Get the HLS segments a service drops (ads, bumpers, dub cards). The server runs
|
||||
### `POST /api/session/{session_id}/license`
|
||||
|
||||
Get the content keys for the DRM. The `mode` field selects one of two modes.
|
||||
A slow answer arrives as a [slow response](#slow-responses).
|
||||
|
||||
**Proxy mode** (`mode: "proxy"`, the default) forwards the client's CDM `challenge` to the service's license endpoint and returns the raw license bytes:
|
||||
|
||||
|
||||
@@ -203,6 +203,18 @@ body that uses an integer `status` and no `error_code` or `timestamp`.
|
||||
(integer `status`, no `error_code`). The `GET /api/health` endpoint is exempt
|
||||
from authentication, so it never returns the key-error shape.
|
||||
|
||||
## 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.
|
||||
|
||||
## Responses with no body
|
||||
|
||||
Not every failure or completion has a JSON body. Some endpoints answer with
|
||||
@@ -257,7 +269,8 @@ For anyone writing a consumer of the API, a durable strategy is:
|
||||
|
||||
!!! 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -277,8 +277,8 @@ generally never touch this directly, but it explains behaviour you might observe
|
||||
|
||||
| Behavior | Value | Notes |
|
||||
|---|---|---|
|
||||
| Idle session TTL | **300s** (5 min) default | Refreshed on every request to the session |
|
||||
| Max concurrent sessions | **100** default | Oldest (least recently used) is evicted when full |
|
||||
| Idle session TTL | **300s** (5 min) default | Refreshed on every request to the session and when a `titles` or `tracks` request ends. A remote session does not expire while a request works on it |
|
||||
| Max concurrent sessions | **100** default | Oldest (least recently used) is evicted when full. A remote session that a request works on goes last |
|
||||
| Auth/input timeout | **600s** (10 min) | Sessions still authenticating or awaiting a prompt use this longer window instead of the TTL |
|
||||
| Cleanup sweep | every 60s | Expired sessions are removed and their input prompts cancelled |
|
||||
|
||||
@@ -403,6 +403,9 @@ layer.
|
||||
- `InputBridge.request_input(prompt, timeout=600)` blocks the sync auth thread on a
|
||||
`threading.Event` until `submit_response()` or `cancel()` fires.
|
||||
- A timeout raises `TimeoutError` and marks the remote session `FAILED`.
|
||||
- A prompt after authentication (in `get_titles()`, `get_tracks()` or licensing) raises
|
||||
`RuntimeError` at once. The client polls for prompts only during authentication, so
|
||||
no answer can come.
|
||||
- `AUTH_INPUT_TIMEOUT = 600.0` seconds. This is also the TTL granted to
|
||||
`AUTHENTICATING` / `PENDING_INPUT` sessions in the store.
|
||||
|
||||
@@ -513,9 +516,18 @@ Source: `unshackle/core/remote_service.py`. This is the canonical consumer of th
|
||||
remote session API and a good template for any client.
|
||||
|
||||
- **`RemoteClient.request`** sets `X-Secret-Key` and `User-Agent: unshackle/<version>`,
|
||||
uses a 120s timeout for `POST` and 30s for `GET`/`DELETE`, and treats any
|
||||
`status_code >= 400` as fatal: it logs `Server error [<error_code>]: <message>`
|
||||
and raises `SystemExit(1)`.
|
||||
uses the server's `timeout` from `remote_services` (default 120s) as the read timeout,
|
||||
and treats any `status_code >= 400` as fatal: it logs `Server error [<error_code>]: <message>`
|
||||
and raises `SystemExit(1)`. A `200` body with `"status": "error"` is also fatal (see
|
||||
the heartbeat below).
|
||||
- **Heartbeat.** The `titles`, `tracks` and `license` routes send a newline every 30s while
|
||||
the service works. The read timeout starts again at each byte, so a slow request stays
|
||||
open for as long as the server works on it, and a reverse proxy does not close it as
|
||||
idle. A route that answers within 30s sends the usual response. After 30s the status
|
||||
is already `200`, so a failure after that time arrives as the usual error body with
|
||||
status `200`. Your client must check `status` in the body. JSON parsers ignore the
|
||||
leading newlines. The server does not gzip-compress a heartbeat response, because gzip
|
||||
output cannot start before the body is ready.
|
||||
- **Retries.** The download-side HTTP session mounts an adapter with
|
||||
`Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504])`.
|
||||
- **Flow.** `authenticate()` → `create` (+ poll `prompt` every 2s up to a 600s
|
||||
|
||||
Reference in New Issue
Block a user