Bản lưu ngày / Snapshot of 2026-10-19 · Xem bản mới nhất / See the latest version
WanduraDocsBeta
BetaDocs đang ở bản beta và còn thay đổi. Thấy chỗ sai, khó hiểu hoặc còn thiếu? Hãy cho chúng tôi biết.
Lịch sử thay đổi

Lịch sử: Phát mượt khi tải cao

Mọi lần sửa trang này, mới nhất ở trên. Mở từng mục để xem đã đổi gì.

← Phát mượt khi tải cao

  1. 13/10/2026 · +63 −3 dòng
    Xem thay đổi
      # Phát mượt khi tải cao
      
    - > **TL;DR** — Khi lưu lượng lên đỉnh, người xem chỉ nên thấy vòng xoay chờ một chút, không bao giờ thấy lỗi. Xin vé **sớm** (tập đang xem cộng một hai tập kế, trong một request), gửi **`viewer_ref` ổn định** để request lặp dùng lại vé, retry `429` và `503` bằng **`Retry-After`, full jitter và ngân sách retry**, và giữ tập hiện tại tiếp tục phát dù tập kế có chuyện gì.
    + > **TL;DR** — Khi lưu lượng lên đỉnh, người xem chỉ nên thấy vòng xoay chờ một chút, không bao giờ thấy lỗi. Mỗi lần mở app chỉ nên tốn **một lượt gọi catalog** (rồi dùng cache của bạn) và **một lượt xin vé cho mỗi tập bắt đầu xem** (tập đó cộng một hai tập kế, trong một request), tất cả qua **một kết nối dùng lại**. Gửi **`viewer_ref` ổn định** để request lặp dùng lại vé, retry `429` và `503` bằng **`Retry-After`, full jitter và ngân sách retry**, và giữ tập hiện tại tiếp tục phát dù tập kế có chuyện gì.
      
      ## Wandura làm gì khi bận {#what-wandura-does}
    ⋯
      Hai hệ quả cho code của bạn: luôn đọc `Retry-After` thay vì tự giả định một con số, và cho request xin vé timeout phía client ít nhất 5 giây.
      
    + ## Gọi catalog một lần {#catalog-once}
    + 
    + `GET /api/delivery/v1/catalog` trả cùng một danh sách cho mọi người xem của tổ chức bạn, và Wandura phục vụ nó từ cache có thể cũ tới 30 giây: hỏi dày hơn không nhận thêm gì mới. API gửi `Cache-Control: no-store` và không có `ETag`, nên không có request có điều kiện và không có `304`. Việc cache là của bạn.
    + 
    + - **Một lần khi mở app, sau đó dùng cache của bạn.** Giữ catalog trong bộ nhớ (thiết bị hoặc backend), làm mới theo hẹn giờ (15–60 phút, xem [Caching](caching.md#what-to-cache)), không bao giờ làm mới theo từng màn hình, từng dòng hay từng tab.
    + - **Chỉ một lượt gọi đang chạy.** Các màn hình cùng cần catalog lúc mở app phải chờ chung một request, không mỗi màn tự gửi một cái. Mười hai lượt gọi song song tính là mười hai request catalog vào hạn mức của bạn và trả cùng một câu mười hai lần.
    + - **Các trang đi từng trang một**, không bao giờ song song ([số request song song](../reference/behaviour.md#retries)).
    + - **Làm mới hỏng thì giữ bản cũ.** Không bao giờ làm trống màn hình vì một lần làm mới thất bại.
    + - **Rải hẹn giờ**: cộng ngẫu nhiên ±20 % vào chu kỳ làm mới để các máy chủ của bạn không cùng gọi vào một giây.
    + 
    + ```ts
    + 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)));
    + ```
    + 
      ## Xin vé sớm {#request-early}
      
    - 1. **Xin theo lô các tập kế.** Khi người xem bắt đầu tập N, gửi một request: N với `"intent": "play"`, cộng N+1 và N+2 dưới dạng prefetch nếu người xem được phép xem chúng. Một lượt gọi, tối đa ba vé.
    + 1. **Xin theo lô, không bao giờ mỗi tập một lượt gọi.** Khi người xem bắt đầu tập N, gửi một request: N với `"intent": "play"`, cộng N+1 và N+2 dưới dạng prefetch nếu người xem được phép xem chúng. Một lượt gọi, tối đa ba vé. Hai dạng cho cùng kết quả (không trộn): `episodes` với 1–3 phần tử (ví dụ bên dưới), hoặc một tập neo cộng `next` (0–2), trả tập neo và các tập kế của cùng phim (ví dụ thứ hai bên dưới).
      2. **Dùng lại vé cho đúng.** Vé gắn với một thiết bị. Chỉ dùng lại cho cùng người xem, cùng thiết bị, cùng tập và cùng intent, và chỉ tới 5 phút trước `expires_at`. Wandura làm việc này cho bạn khi bạn gửi `viewer_ref` ổn định; cache ở backend theo cùng khoá cũng được. Không bao giờ dùng chung vé giữa các thiết bị.
      3. **Gửi `viewer_ref` ổn định**: HMAC của user ID cộng hash của install ID. Không bao giờ theo phiên, không bao giờ chứa dữ liệu cá nhân.
    + 4. **Prefetch tập kế bằng `prefetch`.** Vé của N+1 và N+2 về cùng lô trên và sống 30 phút, nên cứ giữ trong lúc người xem xem tập N. Mọi vé mà người xem chưa bấm xem phải mang `"intent": "prefetch"`: nó không tính là lượt xem, và khi gần trần Wandura từ chối prefetch trước và vẫn cấp vé `play`. Khi người xem tới N+1, gửi lô kế với N+1 là `play` (lượt xem được đếm từ `play`) và N+2, N+3 là prefetch. Đừng prefetch tập mà người xem chưa được xem, và đừng chạy vòng lặp qua cả phim.
      
      ```json
    ⋯
      ```
      
    + Cùng lô đó với tập neo cộng `next`: tập 4 là `play`, tập 5 và 6 được ghi là `prefetch`. Gửi `next` (kể cả `0`) là chuyển response sang dạng lô, mỗi tập một phần tử.
    + 
    + ```json
    + {
    +   "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"
    + }
    + ```
    + 
      Mỗi phần tử trong response có `status` riêng: một phần tử có thể `200` trong khi phần tử khác `404` `not_ready` (quá tập cuối), `429` (một prefetch bị tạm dừng) hoặc `503` kèm `retry_after`. Chỉ retry những phần tử hỏng mà bạn cần.
      
    ⋯
      Endpoint backend dùng nó, với request theo lô và cách xử lý từng phần tử: [Luồng playback và paywall](playback-flow.md#code).
      
    + ### Không để retry thành bão lúc mở app {#no-storm}
    + 
    + - **Một ngân sách retry cho cả process.** Mọi lượt gọi dùng chung một `RetryBudget`. Một màn hình đầy lượt gọi hỏng không được mỗi cái tự retry ba lần.
    + - **Một `429` tạm dừng cả endpoint đó trong process của bạn.** Khi một lượt gọi nhận `429` với `Retry-After: 2`, giữ mọi lượt gọi khác tới endpoint đó 2 giây cộng ngẫu nhiên 0–1 giây. Retry các lượt kia ngay chỉ nhận thêm `429`.
    + - **Chỉ retry thứ có thể thành công.** Không bao giờ retry `400`, `401`, `403`, `404`, `409`, `422`. Một lô chỉ retry các phần tử hỏng, mỗi phần tử sau `retry_after` của riêng nó.
    + - **Không vòng lặp vô hạn.** Mỗi lần retry đều chờ (exponential backoff với full jitter khi không có `Retry-After`) và dừng ở mức trần ở trên.
    + 
    + ## Dùng lại một kết nối {#keep-alive}
    + 
    + Tạo **một HTTP client cho cả process** và dùng nó cho mọi lượt gọi Wandura. Tạo client mới cho mỗi lượt gọi là mở một kết nối HTTPS mới mỗi lần (bắt tay TCP và TLS trước byte đầu tiên), làm mọi lượt gọi chậm đi và ăn mất timeout 5 giây. `fetch` có sẵn của Node đã tự dùng lại kết nối; với `https` thì truyền `new https.Agent({ keepAlive: true })`; Python dùng một `requests.Session()`; Kotlin một `OkHttpClient`; Swift một `URLSession`.
    + 
    + ## Wandura đo gì phía mình {#slo}
    + 
    + Wandura giữ cửa phát vé theo một **mục tiêu nội bộ**, không phải cam kết hợp đồng: API đang beta ([Go-live · Hỗ trợ](../production/go-live.md#support)); hỏi trong kênh của bạn để biết số hiện tại. Cách đo:
    + 
    + | | Cách đo |
    + |---|---|
    + | Availability | Tỷ lệ **vé** được trả lời không có `5xx`, theo tháng UTC. Một lô 3 tập tính 3 vé, mỗi vé có status riêng. |
    + | Latency | Percentile 95 (p95) của vé và của catalog, đo **bên trong Wandura**, từ lúc nhận request tới lúc có câu trả lời. Không gồm mạng và không gồm lúc mở kết nối: người xem chờ con số này cộng mạng và backend của bạn. Một lô là một mẫu latency. |
    + | `4xx` | **Không bao giờ là lỗi** phía Wandura: `400`, `401`, `403`, `404` và `429` là request hoặc hạn mức của bạn, nên không tiêu hao error budget. Một cơn bão `429` có thể để số của Wandura vẫn xanh trong khi người xem của bạn thấy lỗi. |
    + | Không đếm | Các lượt gọi mang header `Wandura-Simulate` (bạn thử màn hình lỗi). |
    + 
    + Đo phía bạn theo cách tương tự: [Observability](../production/observability.md).
    + 
      ## Giữ người xem tiếp tục xem {#in-the-app}
      
    ⋯
      ## Danh sách kiểm tra {#checklist}
      
    - - [ ] Bắt đầu một tập gửi một request: `play` cho tập đó, `prefetch` cho các tập kế đã mở khoá.
    + - [ ] Mở app gửi một lượt gọi catalog; mọi lần đọc khác lấy từ cache của bạn, làm mới theo hẹn giờ và không bao giờ theo từng màn hình.
    + - [ ] Bắt đầu một tập gửi một request xin vé: `play` cho tập đó, `prefetch` cho các tập kế đã mở khoá. Không bao giờ mỗi tập một request.
      - [ ] Vé chỉ được dùng lại theo người xem, thiết bị, tập và intent, không bao giờ dùng chéo thiết bị, tới 5 phút trước `expires_at`.
      - [ ] `viewer_ref` ổn định theo người xem và thiết bị, và không chứa dữ liệu cá nhân.
      - [ ] `429` và `503` được retry sau `Retry-After` cộng jitter; nếu không có thì exponential backoff với full jitter.
      - [ ] Tối đa 3 lần retry cho mỗi request tương tác, và ngân sách retry 10 % mỗi phút.
    + - [ ] Một `429` tạm dừng mọi lượt gọi tới endpoint đó trong process của bạn tới khi hết `Retry-After` cộng jitter.
    + - [ ] Một HTTP client dùng chung có keep-alive cho cả process.
      - [ ] Request xin vé có timeout phía client ít nhất 5 giây.
      - [ ] Prefetch bị từ chối không có tác động nhìn thấy được, và không bao giờ làm dừng tập hiện tại.
    ⋯
      
      - [Rate limit](../concepts/rate-limits.md)
    + - [Caching](caching.md)
    + - [Observability](../production/observability.md)
      - [Lỗi](../concepts/errors.md)
  2. 02/10/2026 · +68 −136 dòng
    Xem thay đổi
      # Phát mượt khi tải cao
      
    - > **TL;DR** — Khi lưu lượng lên đỉnh, người xem chỉ nên thấy vòng xoay chờ một chút, không bao giờ thấy lỗi. Xin vé **sớm** (tập đang xem cộng một hai tập kế trong một request theo lô), **dùng lại** vé khi vé còn hạn, gửi **`viewer_ref` ổn định**, và retry `429` và `503` bằng **exponential backoff, full jitter và `Retry-After`**, trong một **ngân sách retry** nhỏ. Vé prefetch bị từ chối không bao giờ là lỗi, và trục trặc ở tập kế không bao giờ làm dừng tập đang phát.
    - 
    - Mọi client trên trang này là code của bạn: **backend** của bạn nói chuyện với Wandura bằng API key, còn **app** của bạn chỉ nói chuyện với backend của bạn. Lời khuyên áp cho cả hai phía.
    + > **TL;DR** — Khi lưu lượng lên đỉnh, người xem chỉ nên thấy vòng xoay chờ một chút, không bao giờ thấy lỗi. Xin vé **sớm** (tập đang xem cộng một hai tập kế, trong một request), gửi **`viewer_ref` ổn định** để request lặp dùng lại vé, retry `429` và `503` bằng **`Retry-After`, full jitter và ngân sách retry**, và giữ tập hiện tại tiếp tục phát dù tập kế có chuyện gì.
      
      ## Wandura làm gì khi bận {#what-wandura-does}
      
    - Tổ chức của bạn có [rate limit](../concepts/rate-limits.md) cho request xin vé, và server của Wandura có lúc chậm đi một chút. Wandura được thiết kế để xuống cấp nhẹ nhàng:
    - 
    - | Khi | Wandura làm gì |
    + | Khi | Wandura |
      |---|---|
    - | Bạn chạm rate limit playback | Trả `429` `rate_limited` kèm header `Retry-After`. Hạn mức được nạp lại mỗi giây chứ không phải mỗi phút, nên `Retry-After` thường là **1–2 giây**. |
    - | Không cấp kịp một vé | Trả `503` `service_unavailable` kèm `Retry-After` (thường 1–2 giây). Trong một lô, chỉ phần tử bị ảnh hưởng lỗi, kèm `retry_after` riêng. |
    - | Lưu lượng sát rate limit | Từ chối vé **prefetch** trước (`429` kèm `"reason": "prefetch_shed"`), để vé cho tập người xem vừa chạm vẫn được cấp. |
    - | Cùng người xem xin lại cùng tập | Trả lại vé đã cấp khi vé đó còn ít nhất 5 phút, và không đếm lần nữa. Cần `viewer_ref` ổn định. |
    - | Request play đến ngay khi vừa vượt hạn mức | Giữ request tối đa khoảng **0,5 giây** chờ có chỗ, rồi mới trả `429`. |
    - | Database của Wandura chậm | Phục vụ catalog và kiểm giấy phép từ cache có thể cũ tới 5 phút, thay vì báo lỗi. |
    + | Bạn chạm rate limit playback | Trả `429` kèm `Retry-After`, thường 1–2 giây: hạn mức được nạp lại mỗi giây. |
    + | Còn dưới khoảng 20 % hạn mức | Từ chối vé **prefetch** trước (`"reason": "prefetch_shed"`); vé `play` vẫn được cấp bình thường. |
    + | Request `play` đến ngay khi vừa vượt hạn mức | Giữ request tối đa khoảng 0,5 giây rồi mới trả `429`. |
    + | Cùng người xem xin lại cùng tập, trên cùng thiết bị | Trả lại vé đã cấp (còn ≥ 5 phút), không đếm lần nữa. Cần `viewer_ref`. |
    + | Không cấp kịp một vé | `503` `service_unavailable` kèm `Retry-After`; trong một lô, chỉ phần tử đó lỗi. |
    + | Database của Wandura chậm | Phục vụ catalog và kiểm giấy phép từ câu trả lời tốt gần nhất, cũ tối đa 5 phút. |
      
    - Hai hệ quả cho code của bạn: luôn đọc `Retry-After` thay vì tự giả định một con số, và cho request xin vé timeout phía client ít nhất **5 giây**, để một lần giữ ngắn không bị cắt ngang thành lỗi.
    + Hai hệ quả cho code của bạn: luôn đọc `Retry-After` thay vì tự giả định một con số, và cho request xin vé timeout phía client ít nhất 5 giây.
      
      ## Xin vé sớm {#request-early}
      
    - Lúc tốt nhất để xin vé là trước khi người xem cần đến nó.
    - 
    - 1. **Xin theo lô các tập kế.** Khi người xem bắt đầu một tập, gửi một request cho tập đó với `"intent": "play"` kèm `"next": 2`. Bạn nhận tới ba vé trong một lượt gọi; các tập sau được ghi là prefetch, không phải lượt phát. Áp paywall của bạn trước: chỉ xin tập người xem được phép xem, và chỉ dùng `next` khi các tập kế cũng đã mở khoá (nếu không thì tự liệt kê bằng `episodes`).
    - 2. **Dùng lại vé khi còn hạn.** Vé có hạn 30 phút (`expires_at`). Giữ vé ở backend theo người xem + thiết bị + tập (và `intent`), và đưa lại khi người xem mở lại tập đó trên cùng thiết bị. Vé gắn với giá trị thiết bị lúc cấp (`union_info` và `drm_kek`), nên không bao giờ đưa vé đó cho thiết bị khác. Chỉ xin vé mới khi còn dưới khoảng 5 phút.
    - 3. **Gửi `viewer_ref` ổn định.** Dùng cùng một giá trị mờ cho cùng người xem trên cùng thiết bị, ví dụ `<your-user-id>:<device-id>`, không chứa dữ liệu cá nhân. Nhờ đó Wandura nhận ra request lặp (xem bảng trên) và bộ phận hỗ trợ tìm được vé của một người xem.
    + 1. **Xin theo lô các tập kế.** Khi người xem bắt đầu tập N, gửi một request: N với `"intent": "play"`, cộng N+1 và N+2 dưới dạng prefetch nếu người xem được phép xem chúng. Một lượt gọi, tối đa ba vé.
    + 2. **Dùng lại vé cho đúng.** Vé gắn với một thiết bị. Chỉ dùng lại cho cùng người xem, cùng thiết bị, cùng tập và cùng intent, và chỉ tới 5 phút trước `expires_at`. Wandura làm việc này cho bạn khi bạn gửi `viewer_ref` ổn định; cache ở backend theo cùng khoá cũng được. Không bao giờ dùng chung vé giữa các thiết bị.
    + 3. **Gửi `viewer_ref` ổn định**: HMAC của user ID cộng hash của install ID. Không bao giờ theo phiên, không bao giờ chứa dữ liệu cá nhân.
      
      ```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"
      }
      ```
      
    - Một lô tính mỗi tập một vé vào rate limit, và từng vé được xét riêng. Khi gần chạm ngưỡng, phần tử prefetch bị từ chối trước, mỗi phần tử có `429` và `retry_after` riêng, trong khi các phần tử còn lại vẫn nhận vé. Cả request chỉ trả `429` khi không phần tử nào cấp được. Mỗi phần tử trong response có `status` riêng: một phần tử có thể `200` trong khi phần tử khác `404` `not_ready` (quá tập cuối đã công bố), `429`, hoặc `503` kèm `retry_after`. Xem [Tài liệu tham chiếu Playback](../reference/playback.md).
    + Mỗi phần tử trong response có `status` riêng: một phần tử có thể `200` trong khi phần tử khác `404` `not_ready` (quá tập cuối), `429` (một prefetch bị tạm dừng) hoặc `503` kèm `retry_after`. Chỉ retry những phần tử hỏng mà bạn cần.
      
      ## Retry `429` và `503` cho đúng {#retry}
      
    - Hàng nghìn app cùng retry một lúc sẽ biến một nhịp chậm một giây thành sự cố. Hãy rải các lần retry ra:
    - 
    - - **Chỉ retry `429`, `503` và lỗi mạng.** Mọi `4xx` khác sẽ lại hỏng: sửa request thay vì retry. `5xx` lạ: xử như `503`.
    - - **Tôn trọng `Retry-After`.** Không bao giờ retry sớm hơn con số đó. Cộng thêm ngẫu nhiên 0–1 giây, để server của bạn không cùng quay lại trong một giây.
    - - **Không có `Retry-After` thì dùng exponential backoff với full jitter:** chờ một khoảng ngẫu nhiên từ 0 đến `min(cap, base × 2^attempt)`, ví dụ base 0,5 giây và cap 8 giây.
    - - **Giữ ngân sách retry.** Tối đa 3 lần retry cho một request, và số retry nên dưới khoảng 10 % số request trong phút vừa qua. Hết ngân sách thì báo lỗi nhanh thay vì dồn thêm tải.
    - - **Trong một lô, chỉ retry phần tử hỏng**, mỗi phần tử sau `retry_after` của chính nó.
    - 
    - ### TypeScript (backend của bạn) {#typescript}
    + Các luật (status nào, `Retry-After`, full jitter, mức trần, timeout, ngân sách, số request song song) nằm ở [chính sách retry chuẩn](../reference/behaviour.md#retries). Trong 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;
      }
    - ```
      
    - ## Giữ người xem tiếp tục xem {#in-the-app}
    - 
    - Thứ người xem nhìn thấy quan trọng hơn bất kỳ request đơn lẻ nào.
    - 
    - - **Hiện vòng xoay chờ, không hiện lỗi.** Trong lúc backend đang retry, giữ trạng thái đang tải. Chỉ hiện thông báo kèm nút **Thử lại** khi đã hết ngân sách retry, ví dụ sau khoảng 10 giây.
    - - **Tiếp tục phát tập hiện tại.** Request cho tập kế bị hỏng không bao giờ được làm dừng hay tải lại tập đang phát. Thử lại một lúc sau, hoặc khi người xem xem tới cuối tập.
    - - **Prefetch bị từ chối không phải lỗi.** Nếu vé prefetch bị từ chối (`429`, `503`, hoặc thiếu phần tử), không làm gì người xem thấy được. Khi người xem chạm vào tập đó, xin lúc đó với `"intent": "play"`.
    - - **Không bao giờ hiện mã HTTP thô** cho người xem.
    - 
    - ### Kotlin (app Android) {#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 (app iOS) {#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
    -     }
    - }
    + Endpoint backend dùng nó, với request theo lô và cách xử lý từng phần tử: [Luồng playback và 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") }
    - }
    - ```
    + ## Giữ người xem tiếp tục xem {#in-the-app}
      
    - ## Đọc response một cách dễ tính {#tolerant-reader}
    + - **Vòng xoay chờ, không phải lỗi.** Trong lúc backend đang retry, giữ trạng thái đang tải. Chỉ hiện **Retry** khi đã hết ngân sách (khoảng 15 giây).
    + - **Tập hiện tại tiếp tục phát.** Request cho tập kế bị hỏng không bao giờ làm dừng hay tải lại tập đang trên màn hình.
    + - **Prefetch bị từ chối không phải lỗi.** Không làm gì người xem thấy được; xin với `"intent": "play"` khi người xem tới tập đó.
    + - **Không bao giờ hiện mã status thô.**
      
    - API tiếp tục thêm trường, giá trị và mã lỗi. Tích hợp của bạn vẫn chạy nếu nó:
    + Code phía app, bằng Swift và Kotlin, làm đúng như vậy: [iOS](../player-sdk/ios.md#code-controller), [Android](../player-sdk/android.md#code-controller).
      
    - - bỏ qua trường không nhận ra, ví dụ một trường mới cho biết vé được dùng lại;
    - - coi giá trị lạ là trường hợp chung;
    - - xử `error_code` lạ theo nhóm HTTP status: `429` chờ `Retry-After`, `4xx` khác không retry, `5xx` retry có backoff.
    + ## Đọc response một cách dễ tính {#tolerant-reader}
      
    - Luật đầy đủ ở [API tiến hoá](../concepts/api-evolution.md#tolerant-reader).
    + Bỏ qua trường lạ, coi giá trị lạ là trường hợp mặc định, và xử mã lỗi lạ theo nhóm status. Xem [Versioning và API tiến hoá](../concepts/versioning.md#tolerant-reader).
      
      ## Danh sách kiểm tra {#checklist}
      
    - - [ ] Bắt đầu một tập gửi một request theo lô: `"intent": "play"` kèm `next` (hoặc `episodes`) cho các tập kế đã mở khoá.
    - - [ ] Vé được dùng lại theo người xem + thiết bị + tập, không bao giờ dùng chéo thiết bị, tới khoảng 5 phút trước `expires_at`.
    - - [ ] `viewer_ref` ổn định theo người xem + thiết bị, không chứa dữ liệu cá nhân.
    - - [ ] `429` và `503` được retry sau `Retry-After` cộng jitter; không có header thì exponential backoff với full jitter.
    - - [ ] Tối đa 3 lần retry mỗi request, và ngân sách khoảng 10 % retry mỗi phút.
    + - [ ] Bắt đầu một tập gửi một request: `play` cho tập đó, `prefetch` cho các tập kế đã mở khoá.
    + - [ ] Vé chỉ được dùng lại theo người xem, thiết bị, tập và intent, không bao giờ dùng chéo thiết bị, tới 5 phút trước `expires_at`.
    + - [ ] `viewer_ref` ổn định theo người xem và thiết bị, và không chứa dữ liệu cá nhân.
    + - [ ] `429` và `503` được retry sau `Retry-After` cộng jitter; nếu không có thì exponential backoff với full jitter.
    + - [ ] Tối đa 3 lần retry cho mỗi request tương tác, và ngân sách retry 10 % mỗi phút.
      - [ ] Request xin vé có timeout phía client ít nhất 5 giây.
    - - [ ] Người xem thấy vòng xoay chờ khi đang retry, và chỉ thấy nút **Thử lại** sau khoảng 10 giây.
      - [ ] Prefetch bị từ chối không có tác động nhìn thấy được, và không bao giờ làm dừng tập hiện tại.
    - - [ ] Trường, giá trị và mã lỗi lạ không làm hỏng tích hợp.
      
    - ## Liên quan
    + ## Liên quan {#related}
      
      - [Rate limit](../concepts/rate-limits.md)
      - [Lỗi](../concepts/errors.md)
    - - [Playback](../concepts/playback.md)
    - - [Hướng dẫn tích hợp](../integration-guide.md#prefetch)
  3. 02/10/2026 · +22 −17 dòng
    Xem thay đổi
    ⋯
      ## Wandura làm gì khi bận {#what-wandura-does}
      
    - Tổ chức của bạn có [rate limit](../concepts/rate-limits.md) cho request xin vé, và server của Wandura có lúc chậm đi một chút. Wandura được thiết kế để xuống cấp nhẹ nhàng. Hành vi ghi **Đang triển khai** đang được đưa lên; tích hợp theo đúng trang này thì chạy được cả trước lẫn sau.
    + Tổ chức của bạn có [rate limit](../concepts/rate-limits.md) cho request xin vé, và server của Wandura có lúc chậm đi một chút. Wandura được thiết kế để xuống cấp nhẹ nhàng:
      
    - | Khi | Wandura làm gì | Trạng thái |
    - |---|---|---|
    - | Bạn chạm rate limit playback | Trả `429` `rate_limited` kèm header `Retry-After`. | Đang chạy |
    - | Không cấp kịp một vé | Trả `503` `service_unavailable` kèm `Retry-After` (thường 1–2 giây). Trong một lô, chỉ phần tử bị ảnh hưởng lỗi, kèm `retry_after` riêng. | Đang chạy |
    - | Lưu lượng sát rate limit | Từ chối vé **prefetch** trước, để vé cho tập người xem vừa chạm vẫn được cấp. | **Đang triển khai** |
    - | Bạn chạm rate limit playback | Nạp lại hạn mức mỗi giây thay vì mỗi phút, nên `Retry-After` khoảng **1–2 giây** thay vì tới 60. | **Đang triển khai** |
    - | Cùng người xem xin lại cùng tập | Trả lại vé đã cấp khi vé đó còn hạn, và không đếm lần nữa. Cần `viewer_ref` ổn định. | **Đang triển khai** |
    - | Request play đến ngay khi vừa vượt hạn mức | Giữ request tối đa khoảng **0,5 giây** chờ có chỗ, rồi mới trả `429`. | **Đang triển khai** |
    - | Database của Wandura chậm | Phục vụ catalog và kiểm giấy phép từ cache có thể cũ vài phút, thay vì báo lỗi. | **Đang triển khai** |
    + | Khi | Wandura làm gì |
    + |---|---|
    + | Bạn chạm rate limit playback | Trả `429` `rate_limited` kèm header `Retry-After`. Hạn mức được nạp lại mỗi giây chứ không phải mỗi phút, nên `Retry-After` thường là **1–2 giây**. |
    + | Không cấp kịp một vé | Trả `503` `service_unavailable` kèm `Retry-After` (thường 1–2 giây). Trong một lô, chỉ phần tử bị ảnh hưởng lỗi, kèm `retry_after` riêng. |
    + | Lưu lượng sát rate limit | Từ chối vé **prefetch** trước (`429` kèm `"reason": "prefetch_shed"`), để vé cho tập người xem vừa chạm vẫn được cấp. |
    + | Cùng người xem xin lại cùng tập | Trả lại vé đã cấp khi vé đó còn ít nhất 5 phút, và không đếm lần nữa. Cần `viewer_ref` ổn định. |
    + | Request play đến ngay khi vừa vượt hạn mức | Giữ request tối đa khoảng **0,5 giây** chờ có chỗ, rồi mới trả `429`. |
    + | Database của Wandura chậm | Phục vụ catalog và kiểm giấy phép từ cache có thể cũ tới 5 phút, thay vì báo lỗi. |
      
      Hai hệ quả cho code của bạn: luôn đọc `Retry-After` thay vì tự giả định một con số, và cho request xin vé timeout phía client ít nhất **5 giây**, để một lần giữ ngắn không bị cắt ngang thành lỗi.
    ⋯
      
      1. **Xin theo lô các tập kế.** Khi người xem bắt đầu một tập, gửi một request cho tập đó với `"intent": "play"` kèm `"next": 2`. Bạn nhận tới ba vé trong một lượt gọi; các tập sau được ghi là prefetch, không phải lượt phát. Áp paywall của bạn trước: chỉ xin tập người xem được phép xem, và chỉ dùng `next` khi các tập kế cũng đã mở khoá (nếu không thì tự liệt kê bằng `episodes`).
    - 2. **Dùng lại vé khi còn hạn.** Vé có hạn 30 phút (`expires_at`). Giữ vé ở backend theo người xem + tập, và đưa lại khi người xem mở lại tập đó. Chỉ xin vé mới khi còn dưới khoảng 5 phút.
    - 3. **Gửi `viewer_ref` ổn định.** Dùng cùng một giá trị mờ cho cùng người xem trên cùng thiết bị, ví dụ `<your-user-id>:<device-id>`, không chứa dữ liệu cá nhân. Nhờ đó Wandura nhận ra request lặp (**Đang triển khai**, xem bảng trên) và bộ phận hỗ trợ tìm được vé của một người xem.
    + 2. **Dùng lại vé khi còn hạn.** Vé có hạn 30 phút (`expires_at`). Giữ vé ở backend theo người xem + thiết bị + tập (và `intent`), và đưa lại khi người xem mở lại tập đó trên cùng thiết bị. Vé gắn với giá trị thiết bị lúc cấp (`union_info` và `drm_kek`), nên không bao giờ đưa vé đó cho thiết bị khác. Chỉ xin vé mới khi còn dưới khoảng 5 phút.
    + 3. **Gửi `viewer_ref` ổn định.** Dùng cùng một giá trị mờ cho cùng người xem trên cùng thiết bị, ví dụ `<your-user-id>:<device-id>`, không chứa dữ liệu cá nhân. Nhờ đó Wandura nhận ra request lặp (xem bảng trên) và bộ phận hỗ trợ tìm được vé của một người xem.
      
      ```json
    ⋯
      ```
      
    - Một lô tính mỗi tập một vé vào rate limit, và được nhận hoặc từ chối nguyên lô. Mỗi phần tử trong response có `status` riêng: một phần tử có thể `200` trong khi phần tử khác `404` `not_ready` (quá tập cuối đã công bố) hoặc `503` kèm `retry_after`. Xem [Tài liệu tham chiếu Playback](../reference/playback.md).
    + Một lô tính mỗi tập một vé vào rate limit, và từng vé được xét riêng. Khi gần chạm ngưỡng, phần tử prefetch bị từ chối trước, mỗi phần tử có `429` và `retry_after` riêng, trong khi các phần tử còn lại vẫn nhận vé. Cả request chỉ trả `429` khi không phần tử nào cấp được. Mỗi phần tử trong response có `status` riêng: một phần tử có thể `200` trong khi phần tử khác `404` `not_ready` (quá tập cuối đã công bố), `429`, hoặc `503` kèm `retry_after`. Xem [Tài liệu tham chiếu Playback](../reference/playback.md).
      
      ## Retry `429` và `503` cho đúng {#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
    ⋯
      ## Đọc response một cách dễ tính {#tolerant-reader}
      
    - API tiếp tục thêm trường, giá trị và mã lỗi, kể cả trong đợt triển khai này. Tích hợp của bạn vẫn chạy nếu nó:
    + API tiếp tục thêm trường, giá trị và mã lỗi. Tích hợp của bạn vẫn chạy nếu nó:
      
      - bỏ qua trường không nhận ra, ví dụ một trường mới cho biết vé được dùng lại;
    ⋯
      
      - [ ] Bắt đầu một tập gửi một request theo lô: `"intent": "play"` kèm `next` (hoặc `episodes`) cho các tập kế đã mở khoá.
    - - [ ] Vé được dùng lại theo người xem + tập tới khoảng 5 phút trước `expires_at`.
    + - [ ] Vé được dùng lại theo người xem + thiết bị + tập, không bao giờ dùng chéo thiết bị, tới khoảng 5 phút trước `expires_at`.
      - [ ] `viewer_ref` ổn định theo người xem + thiết bị, không chứa dữ liệu cá nhân.
      - [ ] `429` và `503` được retry sau `Retry-After` cộng jitter; không có header thì exponential backoff với full jitter.
    ⋯
  4. 02/10/2026 · Tạo trang
↑↓ chọnEnter mởEsc đóng

Gửi góp ý

Góp ý được gửi thẳng tới đội Wandura, kèm địa chỉ trang này.

0 / 4000