# Smooth playback under load

> **TL;DR** — When traffic peaks, a viewer should see a short spinner, never an error. One app open should cost **one catalog call** (then your own cache) and **one ticket call per episode started** (the episode plus the next one or two, in one request), all over **one reused connection**. Send a **stable `viewer_ref`** so repeats reuse tickets, retry `429` and `503` with **`Retry-After`, full jitter and a retry budget**, and keep the current episode playing whatever happens to the next one.

## What Wandura does when it is busy
| When | Wandura |
|---|---|
| You reach your playback limit | Answers `429` with `Retry-After`, usually 1–2 seconds: the allowance refills every second. |
| Less than about 20 % of the allowance is left | Refuses **prefetch** tickets first (`"reason": "prefetch_shed"`); `play` tickets keep flowing. |
| A `play` request arrives just over the limit | Holds it up to about 0.5 seconds before answering `429`. |
| The same viewer asks again for the same episode, on the same device | Returns the ticket it already issued (≥ 5 minutes left), not counted. Needs `viewer_ref`. |
| A ticket cannot be issued in time | `503` `service_unavailable` with `Retry-After`; in a batch, only that item fails. |
| Wandura's database is slow | Serves the catalog and licence checks from the last good answer, up to 5 minutes old. |

Two consequences for your code: always read `Retry-After` instead of assuming a number, and give ticket requests at least 5 seconds of client timeout.

## Call the catalog once
`GET /api/delivery/v1/catalog` returns the same list to every viewer of your organisation, and Wandura serves it from a cache that can be up to 30 seconds old: asking more often returns nothing new. The API sends `Cache-Control: no-store` and no `ETag`, so there is no conditional request and no `304`. The caching is yours.

- **Once at app open, then from your cache.** Keep the catalog in memory (device or backend), refresh it on a timer (15–60 minutes, see [Caching](https://docs.wandura.ai/developers/guides/caching#what-to-cache)), and never per screen, row or tab.
- **One call in flight.** Screens that all need the catalog at app open must wait for the same request. Twelve parallel calls count as twelve catalog requests against your limit and return the same answer twelve times.
- **Pages one at a time**, never in parallel ([concurrency](https://docs.wandura.ai/developers/reference/behaviour#retries)).
- **A failed refresh keeps the old copy.** Never empty the screen because a refresh failed.
- **Spread the timers**: add a random ±20 % to the refresh interval so your servers do not all call in the same second.

```ts
let inflight: Promise<Catalog> | null = null;
// refreshIfStale(): return the cached catalog if it is younger than your TTL, else GET it once.
export const getCatalog = () => (inflight ??= refreshIfStale().finally(() => (inflight = null)));
```

## Request tickets early
1. **Batch, never one call per episode.** When a viewer starts episode N, send one request: N with `"intent": "play"`, plus N+1 and N+2 as prefetches if the viewer may watch them. One round trip, up to three tickets. Two forms do the same (never mix them): `episodes` with 1–3 items (example below), or an anchor episode plus `next` (0–2), which returns the anchor and the following episodes of the same film (second example below).
2. **Reuse tickets correctly.** A ticket is bound to one device. Reuse it only for the same viewer, the same device, the same episode and the same intent, and only until 5 minutes before `expires_at`. Wandura does this for you when you send a stable `viewer_ref`; a backend cache under the same key is fine too. Never share a ticket across devices.
3. **Send a stable `viewer_ref`**: an HMAC of your user ID plus a hash of the install ID. Never per session, never personal data.
4. **Prefetch the next episode as `prefetch`.** The tickets for N+1 and N+2 arrive in the batch above and live 30 minutes, so hold them while the viewer watches N. Label every ticket the viewer has not asked to play `"intent": "prefetch"`: it is not counted as a view, and near your ceiling Wandura refuses prefetches first and keeps issuing `play` tickets. When the viewer reaches N+1, send the next batch with N+1 as `play` (views are counted from `play`) and N+2, N+3 as prefetch. Do not prefetch an episode the viewer cannot watch yet, and do not loop over a whole film.

```json
{
  "episodes": [
    { "film_ref": "the-heiress-returns", "episode_no": 4, "intent": "play" },
    { "film_ref": "the-heiress-returns", "episode_no": 5, "intent": "prefetch" },
    { "film_ref": "the-heiress-returns", "episode_no": 6, "intent": "prefetch" }
  ],
  "union_info": "<device ID from the Player SDK>",
  "drm_kek": "<device public key from the Player SDK>",
  "viewer_ref": "k3Jd9sQm2vXa7LpT0bWc1e:Zr8uYq2nM4a"
}
```

The same batch with an anchor plus `next`: episode 4 is `play`, episodes 5 and 6 are recorded as `prefetch`. Sending `next` (even `0`) switches the answer to the batch shape, one entry per episode.

```json
{
  "film_ref": "the-heiress-returns",
  "episode_no": 4,
  "next": 2,
  "intent": "play",
  "union_info": "<device ID from the Player SDK>",
  "drm_kek": "<device public key from the Player SDK>",
  "viewer_ref": "k3Jd9sQm2vXa7LpT0bWc1e:Zr8uYq2nM4a"
}
```

Each item of the answer has its own `status`: one can be `200` while another is `404` `not_ready` (past the last episode), `429` (a paused prefetch) or `503` with `retry_after`. Retry only the failed items you need.

## Retry `429` and `503` the right way
The rules (which statuses, `Retry-After`, full jitter, caps, timeouts, budget, concurrency) are the [canonical retry policy](https://docs.wandura.ai/developers/reference/behaviour#retries). In code:

```ts
/** How hard to try. INTERACTIVE = a viewer is waiting; BACKGROUND = a sync job. */
export type RetryPolicy = {
  maxRetries: number; // retries after the first attempt
  baseMs: number; // first backoff step
  capMs: number; // largest backoff step
  timeoutMs: number; // per attempt
  deadlineMs: number; // the whole call, retries included
};
export const INTERACTIVE: RetryPolicy = { maxRetries: 3, baseMs: 500, capMs: 8_000, timeoutMs: 8_000, deadlineMs: 15_000 };
export const BACKGROUND: RetryPolicy = { maxRetries: 5, baseMs: 1_000, capMs: 30_000, timeoutMs: 15_000, deadlineMs: 120_000 };

/** Retry only what can succeed later: network errors, 429, 500, 502 and 503 (except 503 `not_ready`). */
export function isRetryable(status: number | null, errorCode?: string): boolean {
  if (status === null) return true;
  if (status === 503) return errorCode !== 'not_ready';
  return status === 429 || status === 500 || status === 502;
}

/** Wait before retry number `attempt` (0-based): Retry-After (+ up to 1 s jitter) wins, else full jitter under the cap. */
export function backoffMs(attempt: number, retryAfterS: number | null, p: RetryPolicy, random = Math.random): number {
  if (retryAfterS !== null && retryAfterS > 0) return retryAfterS * 1_000 + random() * 1_000;
  return random() * Math.min(p.capMs, p.baseMs * 2 ** attempt);
}

/** Retries may be at most 10 % of the requests of the last minute (never fewer than 10 a minute). */
export class RetryBudget {
  private sent = 0;
  private retries = 0;
  private since = Date.now();
  private roll() {
    if (Date.now() - this.since > 60_000) { this.sent = 0; this.retries = 0; this.since = Date.now(); }
  }
  noteRequest() { this.roll(); this.sent++; }
  tryRetry(): boolean {
    this.roll();
    if (this.retries >= Math.max(10, this.sent * 0.1)) return false;
    this.retries++;
    return true;
  }
}
```

The backend endpoint that uses it, with the batch and the per-item handling: [Playback flow and paywall](https://docs.wandura.ai/developers/guides/playback-flow#code).

### No retry storm at app open
- **One retry budget per process.** Every call draws on the same `RetryBudget`. A screen full of failing calls must not each retry three times.
- **A `429` pauses the whole endpoint in your process.** When one call gets `429` with `Retry-After: 2`, hold every other call to that endpoint for 2 seconds plus a random 0–1 s. Retrying the others at once only earns more `429`s.
- **Retry only what can succeed.** Never retry `400`, `401`, `403`, `404`, `409` or `422`. A batch retries only its failed items, each after its own `retry_after`.
- **No unbounded loops.** Every retry waits (exponential backoff with full jitter when there is no `Retry-After`) and stops at the cap above.

## Reuse one connection
Create **one HTTP client per process** and use it for every Wandura call. A new client per call opens a new HTTPS connection each time (a TCP and TLS handshake before the first byte), which slows every call and eats the 5-second timeout. Node's built-in `fetch` already reuses connections; with `https` pass `new https.Agent({ keepAlive: true })`; in Python use one `requests.Session()`; in Kotlin one `OkHttpClient`; in Swift one `URLSession`.

## What Wandura measures on its side
Wandura holds the ticket door to an **internal goal**, not a contract: the API is in beta ([Go-live · Support](https://docs.wandura.ai/developers/production/go-live#support)); ask in your channel for current figures. What is measured:

|  | How |
|---|---|
| Availability | The share of **tickets** answered without a `5xx`, per UTC month. A batch of 3 counts 3 tickets, each with its own status. |
| Latency | The 95th percentile (p95) of tickets and of the catalog, timed **inside Wandura**, from request received to answer ready. No network and no connection set-up: a viewer waits this plus your network and your backend. A batch is one latency sample. |
| `4xx` | **Never a failure** on Wandura's side: `400`, `401`, `403`, `404` and `429` are your request or your limit, so they do not use up the error budget. A retry storm of `429`s can leave Wandura's numbers green while your viewers see errors. |
| Not counted | Calls that carry the `Wandura-Simulate` header (your tests of error screens). |

Measure your own side the same way: [Observability](https://docs.wandura.ai/developers/production/observability).

## Keep the viewer watching
- **Spinner, not error.** While your backend retries, keep the loading state. Show **Retry** only when the budget is spent (about 15 seconds).
- **The current episode keeps playing.** A failed request for the next episode never stops or reloads the one on screen.
- **A refused prefetch is not a failure.** Do nothing visible; ask with `"intent": "play"` when the viewer gets there.
- **Never show a raw status code.**

The app-side code, in Swift and Kotlin, does exactly this: [iOS](https://docs.wandura.ai/developers/player-sdk/ios#code-controller), [Android](https://docs.wandura.ai/developers/player-sdk/android#code-controller).

## Read responses tolerantly
Ignore unknown fields, treat unknown values as the default case, and handle unknown error codes by status class. See [Versioning and API evolution](https://docs.wandura.ai/developers/concepts/versioning#tolerant-reader).

## Checklist
- [ ] An app open sends one catalog call; every other read comes from your cache, refreshed on a timer and never per screen.
- [ ] Starting an episode sends one ticket request: `play` for it, `prefetch` for the unlocked next ones. Never one request per episode.
- [ ] Tickets are reused only per viewer, device, episode and intent, never across devices, until 5 minutes before `expires_at`.
- [ ] `viewer_ref` is stable per viewer and device and holds no personal data.
- [ ] `429` and `503` are retried after `Retry-After` plus jitter; otherwise exponential backoff with full jitter.
- [ ] At most 3 retries per interactive request, and a 10 % retry budget per minute.
- [ ] A `429` pauses every call to that endpoint in your process until `Retry-After` plus jitter has passed.
- [ ] One shared HTTP client with keep-alive for the whole process.
- [ ] Client timeout of at least 5 seconds on ticket requests.
- [ ] A refused prefetch has no visible effect and never stops the current episode.

## Related
- [Rate limits](https://docs.wandura.ai/developers/concepts/rate-limits)
- [Caching](https://docs.wandura.ai/developers/guides/caching)
- [Observability](https://docs.wandura.ai/developers/production/observability)
- [Errors](https://docs.wandura.ai/developers/concepts/errors)
