Bản lưu ngày / Snapshot of 2026-10-07 · 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.
You are reading an older version of these docs: v1 · Go to the latest (v2)
Change history

History: Smooth playback under load

Every change to this page, newest first. Expand an entry to see what changed.

← Smooth playback under load

  1. 2 Oct 2026 · +70 −138 lines
    Show changes
      # Smooth playback under load
      
    - > **TL;DR** — When traffic peaks, a viewer should see a short spinner, never an error. Ask for tickets **early** (the current episode plus the next one or two in a single batch request), **reuse** a ticket for as long as it is valid, send a **stable `viewer_ref`**, and retry `429` and `503` with **exponential backoff, full jitter and `Retry-After`**, inside a small **retry budget**. A refused prefetch is never an error, and a problem with the next episode never stops the one that is playing.
    - 
    - Every client on this page is your code: your **backend** talks to Wandura with your API key, and your **app** talks only to your backend. The advice applies to both sides.
    + > **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 {#what-wandura-does}
      
    - Your organisation has a [rate limit](../concepts/rate-limits.md) on ticket requests, and Wandura's own servers can slow down for a moment. Wandura is designed to degrade gently:
    - 
    - | When | What Wandura does |
    + | When | Wandura |
      |---|---|
    - | You reach your playback limit | Answers `429` `rate_limited` with a `Retry-After` header. Your allowance refills every second, not once a minute, so `Retry-After` is usually **1–2 seconds**. |
    - | A ticket cannot be issued in time | Answers `503` `service_unavailable` with `Retry-After` (usually 1–2 seconds). In a batch, only the affected item fails, with its own `retry_after`. |
    - | Traffic is close to your limit | Refuses **prefetch** tickets first (`429` with `"reason": "prefetch_shed"`), so tickets for episodes viewers have just tapped keep being issued. |
    - | The same viewer asks again for the same episode | Returns the ticket it already issued, while that ticket still has at least 5 minutes left, and does not count it again. Needs a stable `viewer_ref`. |
    - | A play request arrives just over the limit | Holds it for up to about **0.5 seconds** for capacity to free up, before answering `429`. |
    - | Wandura's database is slow | Serves the catalog and licence checks from a cache that can be up to 5 minutes old, instead of failing. |
    + | 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 allow at least **5 seconds** of client timeout on ticket requests, so a short hold is not cut off and turned into an error.
    + 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 {#request-early}
      
    - The best moment to ask for a ticket is before the viewer needs it.
    - 
    - 1. **Batch the next episodes.** When a viewer starts an episode, send one request for that episode with `"intent": "play"` plus `"next": 2`. You receive up to three tickets in one round trip; the following episodes are recorded as prefetches, not plays. Apply your paywall first: only ask for episodes the viewer may watch, and use `next` only when the following episodes are unlocked too (otherwise list them yourself with `episodes`).
    - 2. **Reuse a ticket while it is valid.** A ticket is valid for 30 minutes (`expires_at`). Keep it on your backend, keyed by viewer, device and episode (and `intent`), and hand it out again when the viewer reopens that episode on the same device. A ticket is bound to the device values it was issued for (`union_info` and `drm_kek`), so never hand it to another device. Request a new one only when fewer than about 5 minutes are left.
    - 3. **Send a stable `viewer_ref`.** Use the same opaque value for the same viewer on the same device, for example `<your-user-id>:<device-id>`, without personal data. It lets Wandura recognise a repeat request (see the table above) and helps support find a viewer's tickets.
    + 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
      {
    -   "film_ref": "the-heiress-returns",
    -   "episode_no": 4,
    -   "next": 2,
    -   "intent": "play",
    +   "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": "u_48213:dev_9a7c"
    +   "viewer_ref": "k3Jd9sQm2vXa7LpT0bWc1e:Zr8uYq2nM4a"
      }
      ```
      
    - A batch counts as one ticket per episode toward your rate limit, and each ticket is admitted on its own. Close to the limit, prefetch items are refused first, each with its own `429` and `retry_after`, while the other items still get their tickets. The whole request is answered `429` only when nothing in it can be issued. Each item in the response has its own `status`: one item can be `200` while another is `404` `not_ready` (past the last published episode), `429`, or `503` with `retry_after`. See [Playback reference](../reference/playback.md).
    + 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 {#retry}
      
    - Thousands of apps retrying at the same moment turn a one-second blip into an outage. Spread your retries out:
    - 
    - - **Retry only `429`, `503` and network failures.** Any other `4xx` will fail again: fix the request instead. Unknown `5xx`: treat like `503`.
    - - **Honour `Retry-After`.** Never retry sooner than it says. Add a random 0–1 second on top, so your servers do not all come back in the same second.
    - - **Without `Retry-After`, use exponential backoff with full jitter:** wait a random time between 0 and `min(cap, base × 2^attempt)`, for example base 0.5 s and cap 8 s.
    - - **Keep a retry budget.** At most 3 retries for one request, and retries should stay under about 10 % of your requests over the last minute. When the budget is spent, fail fast instead of piling on.
    - - **In a batch, retry only the failed items**, each after its own `retry_after`.
    - 
    - ### TypeScript (your backend) {#typescript}
    + The rules (which statuses, `Retry-After`, full jitter, caps, timeouts, budget, concurrency) are the [canonical retry policy](../reference/behaviour.md#retries). In code:
      
      ```ts
    - type Ticket = { vid: string; play_auth_token: string; expires_at: string; protection: string };
    - // A ticket is bound to the device values it was issued for (union_info / drm_kek):
    - // key the cache by viewer + device + episode + intent, never share a ticket across devices.
    - const cache = new Map<string, Ticket>();
    - const cacheKey = (b: Record<string, unknown>) => JSON.stringify([
    -   b.viewer_ref, b.union_info, b.drm_kek, b.episode_id ?? `${b.film_ref}#${b.episode_no}`, b.intent ?? 'play',
    - ]);
    - let recent = { sent: 0, retries: 0, since: Date.now() };
    - 
    - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
    - const budgetLeft = () => {
    -   if (Date.now() - recent.since > 60_000) recent = { sent: 0, retries: 0, since: Date.now() };
    -   return recent.retries < Math.max(10, recent.sent * 0.1);
    + /** 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 async function getTicket(body: Record<string, unknown>): Promise<Ticket> {
    -   const key = cacheKey(body);
    -   const hit = cache.get(key);
    -   if (hit && Date.parse(hit.expires_at) - Date.now() > 5 * 60_000) return hit; // reuse
    - 
    -   for (let attempt = 0; ; attempt++) {
    -     recent.sent++;
    -     let res: Response | null = null;
    -     try {
    -       res = await fetch('https://www.wandura.ai/api/delivery/v1/playback', {
    -         method: 'POST',
    -         headers: { Authorization: `Bearer ${process.env.WANDURA_PLAYBACK_KEY}`, 'Content-Type': 'application/json' },
    -         body: JSON.stringify(body),
    -         signal: AbortSignal.timeout(5_000),
    -       });
    -     } catch { /* network error or timeout: retryable */ }
    - 
    -     if (res?.ok) {
    -       const ticket = (await res.json()) as Ticket; // ignore fields you do not know
    -       cache.set(key, ticket);
    -       return ticket;
    -     }
    -     const retryable = !res || res.status === 429 || res.status >= 500;
    -     if (!retryable || attempt >= 3 || !budgetLeft()) throw new Error(`ticket failed: ${res?.status ?? 'network'}`);
    + 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 };
      
    -     recent.retries++;
    -     const retryAfter = Number(res?.headers.get('Retry-After'));
    -     const wait = retryAfter > 0
    -       ? retryAfter * 1000 + Math.random() * 1000             // honour Retry-After, plus jitter
    -       : Math.random() * Math.min(8_000, 500 * 2 ** attempt); // full jitter
    -     await sleep(wait);
    -   }
    + /** 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;
      }
    - ```
      
    - ## Keep the viewer watching {#in-the-app}
    - 
    - What the viewer sees matters more than any single request.
    - 
    - - **Show a spinner, not an error.** While your backend is retrying, keep the loading state. Show a message with a **Retry** button only when the retry budget is spent, for example after about 10 seconds.
    - - **Keep playing the current episode.** A failed request for the next episode must never stop or reload the one that is playing. Try again a little later, or when the viewer reaches the end.
    - - **A refused prefetch is not a failure.** If a prefetch ticket is refused (`429`, `503`, or a missing item), do nothing visible. When the viewer taps that episode, request it then with `"intent": "play"`.
    - - **Never show a raw status code** to the viewer.
    - 
    - ### Kotlin (Android app) {#kotlin}
    - 
    - ```kotlin
    - // Your backend does the Wandura call and the retries; the app only shows a spinner.
    - suspend fun prefetchNext(episode: Episode) {
    -     runCatching { backend.ticket(episode, intent = "prefetch") }
    -         .onSuccess { prefetched[episode.id] = it }
    -         // Refused or failed: not an error for the viewer. Ask again on tap.
    + /** 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);
      }
      
    - suspend fun play(episode: Episode) {
    -     ui.showSpinner()
    -     val ticket = prefetched.remove(episode.id)?.takeIf { it.validForMinutes() > 5 }
    -         ?: withTimeoutOrNull(10_000) { backend.ticket(episode, intent = "play") }
    -     if (ticket == null) ui.showRetry("Taking longer than usual") else player.start(ticket)
    + /** 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;
    +   }
      }
      ```
      
    - ### Swift (iOS app) {#swift}
    - 
    - ```swift
    - // Your backend does the Wandura call and the retries; the app only shows a spinner.
    - func prefetchNext(_ episode: Episode) async {
    -     // Refused or failed: not an error for the viewer. Ask again on tap.
    -     if let ticket = try? await backend.ticket(episode, intent: .prefetch) {
    -         prefetched[episode.id] = ticket
    -     }
    - }
    + The backend endpoint that uses it, with the batch and the per-item handling: [Playback flow and paywall](playback-flow.md#code).
      
    - func play(_ episode: Episode) async {
    -     ui.showSpinner()
    -     if let t = prefetched.removeValue(forKey: episode.id), t.minutesLeft > 5 {
    -         return player.start(t)
    -     }
    -     do { player.start(try await backend.ticket(episode, intent: .play, timeout: 10)) }
    -     catch { ui.showRetry("Taking longer than usual") }
    - }
    - ```
    + ## Keep the viewer watching {#in-the-app}
      
    - ## Read responses tolerantly {#tolerant-reader}
    + - **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 API keeps adding fields, values and error codes. Your integration stays working if it:
    + The app-side code, in Swift and Kotlin, does exactly this: [iOS](../player-sdk/ios.md#code-controller), [Android](../player-sdk/android.md#code-controller).
      
    - - ignores fields it does not recognise, for example a new field that says a ticket was reused;
    - - treats an unknown value as the generic case;
    - - handles an unknown `error_code` by its HTTP status class: `429` wait `Retry-After`, other `4xx` do not retry, `5xx` retry with backoff.
    + ## Read responses tolerantly {#tolerant-reader}
      
    - The full rules are on [API evolution](../concepts/api-evolution.md#tolerant-reader).
    + Ignore unknown fields, treat unknown values as the default case, and handle unknown error codes by status class. See [Versioning and API evolution](../concepts/versioning.md#tolerant-reader).
      
      ## Checklist {#checklist}
      
    - - [ ] Starting an episode sends one batch request: `"intent": "play"` plus `next` (or `episodes`) for the unlocked next episodes.
    - - [ ] Tickets are reused per viewer, device and episode, never across devices, until about 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; without it, with exponential backoff and full jitter.
    - - [ ] At most 3 retries per request, and a budget of about 10 % retries per minute.
    - - [ ] Ticket requests have a client timeout of at least 5 seconds.
    - - [ ] The viewer sees a spinner while retrying, and a **Retry** button only after about 10 seconds.
    - - [ ] A refused prefetch has no visible effect, and never stops the current episode.
    - - [ ] Unknown fields, values and error codes do not break the integration.
    + - [ ] 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
    + ## Related {#related}
      
      - [Rate limits](../concepts/rate-limits.md)
      - [Errors](../concepts/errors.md)
    - - [Playback](../concepts/playback.md)
    - - [Integration guide](../integration-guide.md#prefetch)
  2. 2 Oct 2026 · +22 −17 lines
    Show changes
    ⋯
      ## What Wandura does when it is busy {#what-wandura-does}
      
    - Your organisation has a [rate limit](../concepts/rate-limits.md) on ticket requests, and Wandura's own servers can slow down for a moment. Wandura is designed to degrade gently. The behaviour labelled **Rolling out** is being deployed now; build your integration as described on this page and it works both before and after.
    + Your organisation has a [rate limit](../concepts/rate-limits.md) on ticket requests, and Wandura's own servers can slow down for a moment. Wandura is designed to degrade gently:
      
    - | When | What Wandura does | Status |
    - |---|---|---|
    - | You reach your playback limit | Answers `429` `rate_limited` with a `Retry-After` header. | Live |
    - | A ticket cannot be issued in time | Answers `503` `service_unavailable` with `Retry-After` (usually 1–2 seconds). In a batch, only the affected item fails, with its own `retry_after`. | Live |
    - | Traffic is close to your limit | Refuses **prefetch** tickets first, so tickets for episodes viewers have just tapped keep being issued. | **Rolling out** |
    - | You reach your playback limit | Refills your allowance every second instead of once a minute, so `Retry-After` is about **1–2 seconds** instead of up to 60. | **Rolling out** |
    - | The same viewer asks again for the same episode | Returns the ticket it already issued, while that ticket is still valid, and does not count it again. Needs a stable `viewer_ref`. | **Rolling out** |
    - | A play request arrives just over the limit | Holds it for up to about **0.5 seconds** for capacity to free up, before answering `429`. | **Rolling out** |
    - | Wandura's database is slow | Serves the catalog and licence checks from a cache that can be a few minutes old, instead of failing. | **Rolling out** |
    + | When | What Wandura does |
    + |---|---|
    + | You reach your playback limit | Answers `429` `rate_limited` with a `Retry-After` header. Your allowance refills every second, not once a minute, so `Retry-After` is usually **1–2 seconds**. |
    + | A ticket cannot be issued in time | Answers `503` `service_unavailable` with `Retry-After` (usually 1–2 seconds). In a batch, only the affected item fails, with its own `retry_after`. |
    + | Traffic is close to your limit | Refuses **prefetch** tickets first (`429` with `"reason": "prefetch_shed"`), so tickets for episodes viewers have just tapped keep being issued. |
    + | The same viewer asks again for the same episode | Returns the ticket it already issued, while that ticket still has at least 5 minutes left, and does not count it again. Needs a stable `viewer_ref`. |
    + | A play request arrives just over the limit | Holds it for up to about **0.5 seconds** for capacity to free up, before answering `429`. |
    + | Wandura's database is slow | Serves the catalog and licence checks from a cache that can be up to 5 minutes old, instead of failing. |
      
      Two consequences for your code: always read `Retry-After` instead of assuming a number, and allow at least **5 seconds** of client timeout on ticket requests, so a short hold is not cut off and turned into an error.
    ⋯
      
      1. **Batch the next episodes.** When a viewer starts an episode, send one request for that episode with `"intent": "play"` plus `"next": 2`. You receive up to three tickets in one round trip; the following episodes are recorded as prefetches, not plays. Apply your paywall first: only ask for episodes the viewer may watch, and use `next` only when the following episodes are unlocked too (otherwise list them yourself with `episodes`).
    - 2. **Reuse a ticket while it is valid.** A ticket is valid for 30 minutes (`expires_at`). Keep it on your backend, keyed by viewer and episode, and hand it out again when the viewer reopens that episode. Request a new one only when fewer than about 5 minutes are left.
    - 3. **Send a stable `viewer_ref`.** Use the same opaque value for the same viewer on the same device, for example `<your-user-id>:<device-id>`, without personal data. It lets Wandura recognise a repeat request (**Rolling out**, see the table above) and helps support find a viewer's tickets.
    + 2. **Reuse a ticket while it is valid.** A ticket is valid for 30 minutes (`expires_at`). Keep it on your backend, keyed by viewer, device and episode (and `intent`), and hand it out again when the viewer reopens that episode on the same device. A ticket is bound to the device values it was issued for (`union_info` and `drm_kek`), so never hand it to another device. Request a new one only when fewer than about 5 minutes are left.
    + 3. **Send a stable `viewer_ref`.** Use the same opaque value for the same viewer on the same device, for example `<your-user-id>:<device-id>`, without personal data. It lets Wandura recognise a repeat request (see the table above) and helps support find a viewer's tickets.
      
      ```json
    ⋯
      ```
      
    - A batch counts as one ticket per episode toward your rate limit, and it is admitted or refused as a whole. Each item in the response has its own `status`: one item can be `200` while another is `404` `not_ready` (past the last published episode) or `503` with `retry_after`. See [Playback reference](../reference/playback.md).
    + A batch counts as one ticket per episode toward your rate limit, and each ticket is admitted on its own. Close to the limit, prefetch items are refused first, each with its own `429` and `retry_after`, while the other items still get their tickets. The whole request is answered `429` only when nothing in it can be issued. Each item in the response has its own `status`: one item can be `200` while another is `404` `not_ready` (past the last published episode), `429`, or `503` with `retry_after`. See [Playback reference](../reference/playback.md).
      
      ## Retry `429` and `503` the right way {#retry}
    ⋯
      ```ts
      type Ticket = { vid: string; play_auth_token: string; expires_at: string; protection: string };
    - const cache = new Map<string, Ticket>(); // key: viewerRef + episode
    + // A ticket is bound to the device values it was issued for (union_info / drm_kek):
    + // key the cache by viewer + device + episode + intent, never share a ticket across devices.
    + const cache = new Map<string, Ticket>();
    + const cacheKey = (b: Record<string, unknown>) => JSON.stringify([
    +   b.viewer_ref, b.union_info, b.drm_kek, b.episode_id ?? `${b.film_ref}#${b.episode_no}`, b.intent ?? 'play',
    + ]);
      let recent = { sent: 0, retries: 0, since: Date.now() };
      
    ⋯
      };
      
    - export async function getTicket(body: Record<string, unknown>, key: string): Promise<Ticket> {
    + export async function getTicket(body: Record<string, unknown>): Promise<Ticket> {
    +   const key = cacheKey(body);
        const hit = cache.get(key);
        if (hit && Date.parse(hit.expires_at) - Date.now() > 5 * 60_000) return hit; // reuse
    ⋯
      ## Read responses tolerantly {#tolerant-reader}
      
    - The API keeps adding fields, values and error codes, including during this rollout. Your integration stays working if it:
    + The API keeps adding fields, values and error codes. Your integration stays working if it:
      
      - ignores fields it does not recognise, for example a new field that says a ticket was reused;
    ⋯
      
      - [ ] Starting an episode sends one batch request: `"intent": "play"` plus `next` (or `episodes`) for the unlocked next episodes.
    - - [ ] Tickets are reused per viewer and episode until about 5 minutes before `expires_at`.
    + - [ ] Tickets are reused per viewer, device and episode, never across devices, until about 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; without it, with exponential backoff and full jitter.
    ⋯
  3. 2 Oct 2026 · Page created
↑↓ selectEnter openEsc close

Send feedback

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

0 / 4000