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_refso repeats reuse tickets, retry429and503withRetry-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
- 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):episodeswith 1–3 items (example below), or an anchor episode plusnext(0–2), which returns the anchor and the following episodes of the same film (second example below). - 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 stableviewer_ref; a backend cache under the same key is fine too. Never share a ticket across devices. - Send a stable
viewer_ref: an HMAC of your user ID plus a hash of the install ID. Never per session, never personal data. - 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 issuingplaytickets. When the viewer reaches N+1, send the next batch with N+1 asplay(views are counted fromplay) 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
429pauses the whole endpoint in your process. When one call gets429withRetry-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 more429s. - Retry only what can succeed. Never retry
400,401,403,404,409or422. A batch retries only its failed items, each after its ownretry_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:
playfor it,prefetchfor 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_refis stable per viewer and device and holds no personal data. -
429and503are retried afterRetry-Afterplus jitter; otherwise exponential backoff with full jitter. - At most 3 retries per interactive request, and a 10 % retry budget per minute.
- A
429pauses every call to that endpoint in your process untilRetry-Afterplus 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
Updated 13 Oct 2026 · Change history (4)