On this page
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_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.
Request tickets early
- 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. - 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.
{
"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. 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.
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
- Starting an episode sends one request:
playfor it,prefetchfor the unlocked next ones. - 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.
- 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 2 Oct 2026 · Change history (3)