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:
imSp4rky
2026-09-24 10:18:03 -06:00
parent c06b00ec63
commit 91db1fa0b1
21 changed files with 377 additions and 42 deletions
+20 -2
View File
@@ -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:
+14 -1
View File
@@ -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.
+17 -5
View File
@@ -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