Bản lưu ngày / Snapshot of 2026-10-16 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaThese docs are in beta and change often. Something wrong, unclear or missing? Tell us.
Guides

Smooth playback under load

Markdown
On this page

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), 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).
  • 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.
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.
{
  "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.

{
  "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. In code:

/** 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.

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 429s.
  • 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); 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 429s 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.

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, Android.

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.

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.

Updated 13 Oct 2026 · Change history (4)

↑↓ selectEnter openEsc close

Send feedback

Your message goes straight to the Wandura team. It includes the address of this page.

0 / 4000