# Smooth playback under load

> **TL;DR** — When traffic peaks, a viewer should see a short spinner, never an error. Ask for tickets **early** (the episode plus the next one or two, in one request), 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.

## Request tickets early
1. **Batch the next episodes.** 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.
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.

```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"
}
```

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).

## 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
- [ ] Starting an episode sends one request: `play` for it, `prefetch` for the unlocked next ones.
- [ ] 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.
- [ ] 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)
- [Errors](https://docs.wandura.ai/developers/concepts/errors)
