Web Totals

Học từ Facebook #86: Lịch retry, backoff có trần, Fibonacci, báo CDN

Ba lịch retry trong code Facebook: upload với backoff có trần và jitter ±50 ms, player live chờ theo Fibonacci nhưng bỏ qua delay, báo lỗi CDN mỗi N ngày.

hunghg255hunghg25532 min read
#facebook#retry#backoff#video#live-streaming#monitoring
Học từ Facebook #86: Lịch retry, backoff có trần, Fibonacci, báo CDN

"Lỗi thì thử lại" là một dòng code. "Thử lại sau bao lâu, bao nhiêu lần, khi nào bỏ cuộc, và làm sao để một triệu client không cùng thử lại một lúc" là cả một chính sách.

Bài #62 về upload tiếp tục được có nhắc tới retryState.handleRetry mà không có phần cài đặt. Bài này đọc phần đó, cùng hai lịch retry khác trong code Facebook: player video live khi manifest lỗi, và cách trình duyệt báo "file JavaScript trên CDN có vấn đề" mà không làm ngập server.

Tip

Tóm tắt

  • VideoUploadRetries (bundle 151.js): delay min(400 + 100·1,5^(n−1), 8000) ± 50 ms, dừng khi quá 500 lần hoặc quá 1 giờ, mọi tham số đổi được qua thử nghiệm A/B. Jitter ±50 ms quá nhỏ: 1000 client lỗi cùng lúc thì vẫn retry gần như cùng nhịp.
  • Player live (handleOzStreamErrorEvent, OzMpdUpdater): phân loại lỗi theo HTTP code, chờ theo dãy Fibonacci 0,1,1,2,3,5,8,13,21,34 giây, hết dãy thì bỏ cuộc. Nhưng hàm retry(delay) mà OzMpdUpdater truyền vào bỏ qua delay, và nhánh xử lý mã 410 trong đường cũ không bao giờ chạy tới.
  • SyntaxErrorMonitor (bundle 22.js): gặp SyntaxError thì báo về server, tối đa một lần mỗi N ngày, nhớ bằng số ngày kể từ 2010-01-01 trong cookie.
  • Quy tắc mang về: backoff cần trần, cần giới hạn tổng, cần jitter tỉ lệ với delay; và delay tính ra phải thật sự được dùng.

#Lịch 1: backoff luỹ thừa có trần cho upload video

#Facebook làm thế nào?

VideoUploadRetries.createState(policy) trả một object có handleRetry và resetDelay. Lõi:

let d = Math.min(400 + 100 * 1.5 ** (delayRetry - 1), 8000);   // initial + exp_base · mult^(n−1), trần max
d += (Random.random() - 0.5) * 100;                            // jitter ± 50 ms
// dừng nếu: predicate() === false, hoặc totalRetry ≥ 500, hoặc đã quá 1 giờ kể từ createState
js
Code gốc đã đổi tên biến: VideoUploadRetries
const DEFAULT = {
  backoff_ms: { initial: 400, jitter: 100, exp_base: 100, exp_multiplier: 1.5, max: 8000 },
  limits: { total_retry_count: 500, total_time_ms: 60 * 60 * 1000 },
};

delay() {
  let d = b.initial + b.exp_base * Math.pow(b.exp_multiplier, this.delayRetry - 1);
  d = Math.min(d, b.max);
  d += (Random.random() - 0.5) * b.jitter;          // ± 50 ms
  return Math.round(d);
}

handleRetry({ predicate, retryHandler, failureHandler, context }) {
  const reason = predicate?.() === false ? 'predicate'
    : limits.delay_retry_count != null && this.delayRetry >= limits.delay_retry_count ? 'delay-retries'
    : limits.total_retry_count != null && this.totalRetry >= limits.total_retry_count ? 'total-retries'
    : limits.total_time_ms != null && limits.total_time_ms <= Date.now() - this.startedAt ? 'total-time'
    : null;
  if (reason) { emit('retries-abandoned', { reason, … }); return failureHandler(); }
  this.delayRetry++; this.totalRetry++;
  const ms = this.delay();
  emit('retry-attempted', { backoff_ms: ms, … });
  retryHandler(ms);
}
js

Mỗi tham số trong DEFAULT có thể bị ghi đè bởi một tham số thử nghiệm (qex._("1661399") tới "1661406"): đội upload đổi được độ dài chờ, trần, giới hạn cho một nhóm người dùng mà không cần deploy.

Tôi chạy lại module thật bằng node với Random.random() = 0.5 (bỏ jitter). Dãy delay: 500, 550, 625, 738, 906, 1159, 1539, 2109, 2963, 4244, 6167, 8000, 8000… ms. Ba điểm:

  • Có hai bộ đếm. delayRetry quyết định độ dài chờ và được resetDelay() đặt về 0 khi có tiến triển (upload thêm được một chunk). totalRetry không bao giờ reset. Một upload chập chờn kéo dài vẫn quay lại delay ngắn sau mỗi lần thành công, nhưng tổng số lần thử vẫn bị chặn.
  • Hai giới hạn chồng nhau. Ở trần 8 giây, 500 lần là hơn 66 phút, nên giới hạn 1 giờ luôn chạm trước. delay_retry_count mặc định không đặt.
  • Jitter ±50 ms trên delay tới 8000 ms là ±0,6%. Khi server upload sập, các client đang upload cùng lỗi trong vài chục mili giây. Chúng retry theo cùng một dãy, lệch nhau không đáng kể, nên mỗi đợt retry đổ vào server gần như cùng lúc. Tab 1 của demo cho thấy: lúc server vừa sống lại, jitter ±50 ms dồn khoảng 500 request vào một khung 100 ms, full jitter khoảng 50.

#Lịch 2: poll manifest live và dãy Fibonacci

#Facebook làm thế nào?

Video live dùng DASH (xem bài #60): manifest (MPD) liệt kê các segment, và với live, manifest phải được tải lại liên tục để thấy segment mới. OzMpdUpdater (trong 26.VideoPlayer/all.js) hẹn giờ minimumUpdatePeriod giây (khai báo trong MPD) rồi tải lại. Có cờ để dừng poll khi người xem pause, và dừng hẳn khi MPD chuyển từ dynamic sang static (live đã thành video thường).

Khi tải manifest lỗi, OzMpdUpdater phát sự kiện manifestFetchError kèm một object điều khiển, và handleOzStreamErrorEvent quyết định theo thứ tự:

  1. Mã nằm trong network_end_broadcasts_json: endStream().
  2. Mã nằm trong network_skip_json: retry() ngay.
  3. Đã thử quá 10 lần (FIB[retryAttemptCount] là undefined): không làm gì, tức bỏ cuộc và player báo lỗi.
  4. Mã nằm trong network_reload_mpd_json: retry(FIB[n] * 1000) với FIB = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34].
  5. Mã có trong bảng network_retry_intervals_json (mặc định {"0":1000, "404":2000, "502":1000, "503":1000, "504":1000}): retry(bảng[mã]).
  6. Còn lại: không làm gì, player báo lỗi.

Cả ba danh sách mã mặc định là []: mọi thứ do cấu hình từ server quyết định.

Code gốc đã đổi tên biến: handleOzStreamErrorEvent
const FIB = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34];
function handle(config, event) {
  const code = event.error.getExtra().code;
  if (json(config, 'network_end_broadcasts_json', '[]').includes(code)) return event.endStream();
  if (json(config, 'network_skip_json', '[]').includes(code)) return event.retry();
  const fib = config.getBool('back_off_pdash_504_retry', true) ? FIB[event.retryAttemptCount] : 0;
  const reloadCodes = json(config, 'network_reload_mpd_json', '[]');
  if (config.getBool('back_off_pdash_504_retry', true) && fib == null && (!fixFlag || reloadCodes.includes(code))) return; // hết dãy
  if (fib != null && reloadCodes.includes(code)) return event.retry(fib * 1000);
  const intervals = json(config, 'network_retry_intervals_json', '{"0":1000,"404":2000,"502":1000,"503":1000,"504":1000}');
  if (code != null && intervals[code] != null) return event.retry(intervals[code]);
  // không gọi gì: OzMpdUpdater phát lỗi cho player
}
js

Còn object điều khiển mà OzMpdUpdater tạo ra:

const control = {
  endStream() { handled(); this.ended = true; if (gracefully410) this.emit('streamGone'); },
  retry(delay) { handled(); this.setupUpdateLoop(); },   // ← delay không được dùng
  retryAttemptCount: this.requestCount - 1,
  consecutiveFailuresForErrorCode, error, isInitialRequest: false,
};
js

Bug 1: delay bị bỏ qua. retry(delay) gọi setupUpdateLoop(), hàm hẹn giờ theo minimumUpdatePeriod. Bảng Fibonacci, bảng delay theo mã, tất cả tính ra rồi không được dùng: manifest lỗi thì vẫn bị tải lại đúng nhịp như khi khoẻ. Tôi chạy handleOzStreamErrorEvent thật bằng node với network_reload_mpd_json = ["504"]: nó xin 0, 1000, 1000, 2000, 3000, 5000, 8000, 13000, 21000, 34000 ms cho 10 lần lỗi 504 liên tiếp, rồi im (bỏ cuộc). Với minimumUpdatePeriod = 2 giây, 10 lần đó diễn ra trong khoảng 20 giây thay vì 88 giây. Một sự cố 504 kéo dài 40 giây làm player báo lỗi (demo, nút "504 kéo dài 40 giây"), trong khi lịch Fibonacci thật sự sẽ chờ qua được.

Bug 2: nhánh 410 không bao giờ chạy. Đường xử lý cũ (khi cờ handle_mpd_retries_outside_oz_mpd_updater tắt):

const handled = ['404', '503'];                 // thêm '0' nếu có cờ live_gracefully_handle_no_network
if (code && handled.indexOf(code) > -1) switch (code) {
  case '410': this.ended = true; ok = true; break;   // không bao giờ tới: '410' không có trong danh sách
  case '404': if (++this.count404 >= maxTries) break;  // rơi xuống
  case '503': case '0': ok = true; break;
}
js

indexOf lọc trước, mà '410' không có trong mảng, nên case '410' là code chết. HTTP 410 Gone là cách tự nhiên để server nói "buổi live đã kết thúc"; ở đường này nó thành lỗi chung của player. Đường mới xử lý được 410 nếu cấu hình network_end_broadcasts_json có chứa nó; mặc định là [].

#Phát hiện stream đứng

OzStreamInterruptChecker nhận mỗi lần manifest được cập nhật, đọc endTime của khoảng thời gian cuối. Nếu nó không đổi qua stream_interrupt_check_mpd_stale_count_threshold lần liên tiếp, phát streamInterruptAt (người phát live mất mạng, server vẫn trả manifest cũ). Khi endTime nhích lại, phát streamResumedAt. Không cần server báo gì thêm: chính việc manifest "đứng yên" là tín hiệu.

flowchart TD
  P["Tải manifest"] -->|"ok"| S{"endTime có nhích?"}
  S -->|"không, N lần liền"| I["streamInterruptAt"]
  S -->|"có"| W["Chờ minimumUpdatePeriod"]
  I --> W
  W --> P
  P -->|"lỗi, mã X"| H{"handleOzStreamErrorEvent"}
  H -->|"mã kết thúc"| E["endStream"]
  H -->|"mã trong bảng"| R["retry với delay Fibonacci hoặc theo mã"]
  H -->|"hết dãy hoặc không có luật"| F["Player báo lỗi"]
  R -->|"bản gốc: bỏ qua delay"| W
  R -->|"bản viết lại: chờ đúng delay"| P

#Lịch 3: báo lỗi CDN tối đa một lần mỗi N ngày

#Facebook làm thế nào?

Một SyntaxError khi chạy JavaScript của chính trang thường không phải lỗi lập trình (code đã qua build và test), mà là file bị hỏng trên đường tới: CDN trả file cắt cụt, proxy chèn nội dung, hai phiên bản lệch nhau. SyntaxErrorMonitor nghe mọi lỗi qua ErrorPubSub. Toàn bộ module, đã đổi tên biến:

const COOKIE = 'js_ver', DAY = 864e5, EPOCH = 1262304e6;   // 2010-01-01T00:00:00Z
function onError(error) {
  if (error.name != 'SyntaxError' && !/syntaxerror/i.test(error.message)) return;
  let last = Cookie.get(COOKIE);
  const today = Math.floor((Date.now() - EPOCH) / DAY);
  if (!last || today - last >= config.bump_freq_day) {
    Cookie.set(COOKIE, today);
    if (!last) last = today;
    new Image().src = `/ajax/js_bump/?cdn_config=${encodeURIComponent(config.cdn_config)}&days=${today}&last_update=${last}`;
  }
}
js
  • Giới hạn tần suất phía client, không cần server nhớ gì. Mỗi trình duyệt tự nhớ lần báo cuối. Lỗi CDN ảnh hưởng một triệu người thì server nhận tối đa một triệu beacon mỗi N ngày, không phải một triệu mỗi lần tải trang.
  • Số ngày thay cho timestamp. Hôm nay (28/10/2026) là ngày 6144 kể từ 2010-01-01: bốn chữ số trong cookie thay vì mười ba. Cookie đi kèm mọi request tới domain, nên ngắn là đáng giá.
  • new Image().src là beacon cổ điển: GET không cần CORS, không chờ phản hồi.
  • cdn_config gửi kèm cho biết người này đang được phục vụ bởi cấu hình CDN nào. Có lẽ server dùng số beacon theo cdn_config để biết CDN nào đang hỏng, và "bump" (đổi phiên bản, xoá cache) cho nhóm đó, đúng như tên endpoint js_bump.

Tôi chạy module thật với bump_freq_day = 3 qua 8 ngày, mỗi ngày hai lỗi: beacon bắn ở ngày 6144, 6147, 6150, đúng một lần mỗi 3 ngày.

Một giới hạn nhỏ: nếu đồng hồ máy từng chạy nhanh (cookie ghi ngày ở tương lai) rồi được chỉnh lại, today - last âm và module im lặng cho tới khi ngày thật vượt qua ngày trong cookie. Demo, tab 3, nút "Đồng hồ máy chạy nhanh 30 ngày".

#Bên trong trình duyệt

  • Đàn bò tót (thundering herd): khi một server sập, mọi client đang kết nối nhận lỗi trong cùng khoảng vài chục mili giây. Nếu chúng retry theo cùng một hàm của số lần thử, chúng giữ nguyên sự đồng bộ đó qua mọi lần thử. Jitter phải đủ lớn so với delay để phá sự đồng bộ: full jitter chọn ngẫu nhiên trong [0, delay], equal jitter trong [delay/2, delay].
  • Bộ hẹn giờ của tab ẩn bị bóp. Chrome có thể dồn timer của tab nền về một lần mỗi phút. Retry 500 ms ở tab nền thành retry 60 giây; tính "tổng thời gian" bằng Date.now() như bản gốc (thay vì cộng các delay) là đúng.
  • Lỗi cú pháp của script được báo qua sự kiện error của window, message dạng "Uncaught SyntaxError: …". Với script khác origin không có crossorigin và header CORS, trình duyệt giấu chi tiết, chỉ còn "Script error.": không có chữ SyntaxError để bắt. Có lẽ đó là lý do bundle Facebook được nạp với CORS.
  • HTTP 410 Gone khác 404 ở chỗ nói "đã từng có, giờ hết hẳn, đừng hỏi nữa". Với live, đó là tín hiệu kết thúc sạch nhất.

#Viết lại

Ba kiểu jitter cho cùng một công thức delay:

src/lib/retrySchedules.ts
export function backoffDelay(policy: BackoffPolicy, attempt: number, random = Math.random) {
  const base = Math.min(policy.initial + policy.expBase * policy.multiplier ** (attempt - 1), policy.max);
  const r = random();
  switch (policy.jitterMode) {
    case 'full':
      return Math.round(base * r);
    case 'equal':
      return Math.round(base / 2 + (base / 2) * r);
    default:
      return Math.max(0, Math.round(base + (r - 0.5) * policy.jitter));
  }
}
ts

Phân loại lỗi manifest, thứ tự giống handleOzStreamErrorEvent: mã kết thúc, mã bỏ qua, hết dãy thì bỏ cuộc, rồi Fibonacci hoặc delay theo mã.

src/lib/retrySchedules.ts
export function classifyStreamError(code: string | null, attempt: number, cfg: StreamErrorConfig = DEFAULT_STREAM_CONFIG): StreamErrorAction {
  if (code != null && cfg.endCodes.includes(code)) return { action: 'end' };
  if (code != null && cfg.skipCodes.includes(code)) return { action: 'retry', delayMs: 0 };
  const fib = FIB_BACKOFF_SEC[attempt];
  if (fib == null) return { action: 'give-up' }; // hết bảng: 10 lần thử
  if (code != null && cfg.fibCodes.includes(code)) return { action: 'retry', delayMs: fib * 1000 };
  const fixed = code != null ? cfg.retryIntervals[code] : undefined;
  return fixed != null ? { action: 'retry', delayMs: fixed } : { action: 'give-up' };
}
ts

Vòng poll: thành công thì đặt attempt = 0, báo cho bộ phát hiện stream đứng, hẹn lần sau sau periodMs. Phần lỗi khác bản gốc ở đúng một dòng, schedule(next.delayMs):

src/lib/retrySchedules.ts
    } catch (err) {
      if (stopped) return;
      const code = String((err as { code?: unknown })?.code ?? '0');
      const next = classifyStreamError(code, attempt++, config);
      onEvent?.({ type: 'error', at: now(), code, next });
      if (next.action === 'retry') schedule(next.delayMs); // bản gốc bỏ qua delay này
      else if (next.action === 'end') {
        stopped = true;
        onEvent?.({ type: 'ended', at: now() });
      } else {
        stopped = true;
        onEvent?.({ type: 'failed', at: now() });
      }
    }
ts

Giới hạn báo lỗi theo ngày, thêm trường hợp ngày lưu nằm ở tương lai:

src/lib/retrySchedules.ts
export function shouldReport(stored: string | null, nowMs: number, freqDays: number): { report: boolean; store: number; lastDay: number | null } {
  const today = dayNumber(nowMs);
  const last = stored != null && /^\d+$/.test(stored) ? Number(stored) : null;
  if (last == null || last > today || today - last >= freqDays) return { report: true, store: today, lastDay: last };
  return { report: false, store: last, lastDay: last };
}
ts
Xem toàn bộ src/lib/retrySchedules.ts
src/lib/retrySchedules.ts
/**
 * Ba kiểu lịch retry trong code Facebook:
 * 1. VideoUploadRetries: backoff luỹ thừa có trần + jitter, dừng theo số lần hoặc tổng thời gian.
 * 2. Player live (OzMpdUpdater + handleOzStreamErrorEvent): phân loại lỗi theo HTTP code,
 *    chờ theo dãy Fibonacci, phát hiện stream đứng khi manifest không nhích.
 * 3. SyntaxErrorMonitor: báo lỗi CDN tối đa một lần mỗi N ngày, nhớ bằng số ngày trong cookie.
 * Khác bản gốc: có full jitter, lịch retry của manifest thật sự dùng delay được trả về,
 * mã 410 kết thúc stream, đồng hồ lùi không làm kẹt bộ đếm ngày.
 */

// ───────────────────────── 1. Backoff có trần ─────────────────────────

export type JitterMode = 'fixed' | 'full' | 'equal';

export interface BackoffPolicy {
  /** delay = min(initial + expBase · multiplier^(n−1), max) rồi cộng jitter */
  initial: number;
  expBase: number;
  multiplier: number;
  max: number;
  /** 'fixed': ± jitter/2 ms như bản gốc; 'full': ngẫu nhiên trong [0, delay]; 'equal': [delay/2, delay] */
  jitterMode: JitterMode;
  jitter: number;
  limits: { delayRetries?: number; totalRetries?: number; totalTimeMs?: number };
}

/** Giá trị mặc định của VideoUploadRetries */
export const UPLOAD_POLICY: BackoffPolicy = {
  initial: 400,
  expBase: 100,
  multiplier: 1.5,
  max: 8000,
  jitterMode: 'fixed',
  jitter: 100,
  limits: { totalRetries: 500, totalTimeMs: 60 * 60 * 1000 },
};

export function backoffDelay(policy: BackoffPolicy, attempt: number, random = Math.random) {
  const base = Math.min(policy.initial + policy.expBase * policy.multiplier ** (attempt - 1), policy.max);
  const r = random();
  switch (policy.jitterMode) {
    case 'full':
      return Math.round(base * r);
    case 'equal':
      return Math.round(base / 2 + (base / 2) * r);
    default:
      return Math.max(0, Math.round(base + (r - 0.5) * policy.jitter));
  }
}

export type AbandonReason = 'predicate' | 'delay-retries' | 'total-retries' | 'total-time';
export type RetryDecision =
  | { action: 'retry'; delayMs: number; delayRetry: number; totalRetry: number }
  | { action: 'abandon'; reason: AbandonReason; delayRetry: number; totalRetry: number };

export function createRetryState(policy: BackoffPolicy = UPLOAD_POLICY, now = Date.now, random = Math.random) {
  const startedAt = now();
  let delayRetry = 0; // reset khi có tiến triển (vd upload thêm được một chunk)
  let totalRetry = 0; // không bao giờ reset
  return {
    next(shouldRetry?: () => boolean): RetryDecision {
      const { limits } = policy;
      let reason: AbandonReason | null = null;
      if (shouldRetry && !shouldRetry()) reason = 'predicate';
      else if (limits.delayRetries != null && delayRetry >= limits.delayRetries) reason = 'delay-retries';
      else if (limits.totalRetries != null && totalRetry >= limits.totalRetries) reason = 'total-retries';
      else if (limits.totalTimeMs != null && now() - startedAt >= limits.totalTimeMs) reason = 'total-time';
      if (reason) return { action: 'abandon', reason, delayRetry, totalRetry };
      delayRetry++;
      totalRetry++;
      return { action: 'retry', delayMs: backoffDelay(policy, delayRetry, random), delayRetry, totalRetry };
    },
    /** gọi khi request thành công: lần lỗi sau bắt đầu lại từ delay nhỏ nhất */
    resetDelay() {
      delayRetry = 0;
    },
  };
}

// ───────────────────────── 2. Lỗi manifest live ─────────────────────────

/** giây, theo số lần thử lại liên tiếp (bản gốc: [0,1,1,2,3,5,8,13,21,34]) */
export const FIB_BACKOFF_SEC = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34];

export interface StreamErrorConfig {
  /** mã nghĩa là buổi live đã kết thúc */
  endCodes: string[];
  /** mã bỏ qua: thử lại ngay */
  skipCodes: string[];
  /** mã thử lại theo bảng Fibonacci */
  fibCodes: string[];
  /** mã thử lại sau một khoảng cố định (ms) */
  retryIntervals: Record<string, number>;
}

export const DEFAULT_STREAM_CONFIG: StreamErrorConfig = {
  endCodes: ['410'],
  skipCodes: [],
  fibCodes: ['504'],
  retryIntervals: { '0': 1000, '404': 2000, '502': 1000, '503': 1000, '504': 1000 },
};

export type StreamErrorAction =
  | { action: 'end' }
  | { action: 'retry'; delayMs: number }
  | { action: 'give-up' };

/** attempt: số lần đã thử lại liên tiếp trước lần này (0 = lỗi đầu tiên) */
export function classifyStreamError(code: string | null, attempt: number, cfg: StreamErrorConfig = DEFAULT_STREAM_CONFIG): StreamErrorAction {
  if (code != null && cfg.endCodes.includes(code)) return { action: 'end' };
  if (code != null && cfg.skipCodes.includes(code)) return { action: 'retry', delayMs: 0 };
  const fib = FIB_BACKOFF_SEC[attempt];
  if (fib == null) return { action: 'give-up' }; // hết bảng: 10 lần thử
  if (code != null && cfg.fibCodes.includes(code)) return { action: 'retry', delayMs: fib * 1000 };
  const fixed = code != null ? cfg.retryIntervals[code] : undefined;
  return fixed != null ? { action: 'retry', delayMs: fixed } : { action: 'give-up' };
}

export type PollEvent =
  | { type: 'ok'; at: number; endTime: number }
  | { type: 'error'; at: number; code: string; next: StreamErrorAction }
  | { type: 'interrupted'; at: number; endTime: number }
  | { type: 'resumed'; at: number; endTime: number }
  | { type: 'ended'; at: number }
  | { type: 'failed'; at: number };

export interface ManifestPollerOptions {
  /** tải manifest; trả endTime của đoạn cuối, hoặc ném { code } */
  fetch: () => Promise<number>;
  /** minimumUpdatePeriod của MPD, ms */
  periodMs: number;
  /** số lần cập nhật liên tiếp endTime không đổi thì coi là stream đứng (0 = tắt) */
  staleThreshold?: number;
  config?: StreamErrorConfig;
  now?: () => number;
  setTimer?: (fn: () => void, ms: number) => unknown;
  clearTimer?: (h: unknown) => void;
  onEvent?: (e: PollEvent) => void;
}

export function createManifestPoller(opts: ManifestPollerOptions) {
  const {
    fetch,
    periodMs,
    staleThreshold = 3,
    config = DEFAULT_STREAM_CONFIG,
    now = Date.now,
    setTimer = (fn, ms) => setTimeout(fn, ms),
    clearTimer = (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
    onEvent,
  } = opts;
  let timer: unknown = null;
  let stopped = false;
  let attempt = 0;
  const stale = createStaleDetector(staleThreshold);

  const schedule = (ms: number) => {
    if (stopped) return;
    timer = setTimer(poll, ms);
  };

  async function poll() {
    timer = null;
    try {
      const endTime = await fetch();
      if (stopped) return;
      attempt = 0;
      onEvent?.({ type: 'ok', at: now(), endTime });
      const change = stale.update(endTime);
      if (change) onEvent?.({ type: change, at: now(), endTime });
      schedule(periodMs);
    } catch (err) {
      if (stopped) return;
      const code = String((err as { code?: unknown })?.code ?? '0');
      const next = classifyStreamError(code, attempt++, config);
      onEvent?.({ type: 'error', at: now(), code, next });
      if (next.action === 'retry') schedule(next.delayMs); // bản gốc bỏ qua delay này
      else if (next.action === 'end') {
        stopped = true;
        onEvent?.({ type: 'ended', at: now() });
      } else {
        stopped = true;
        onEvent?.({ type: 'failed', at: now() });
      }
    }
  }

  return {
    start() {
      stopped = false;
      schedule(0);
    },
    stop() {
      stopped = true;
      if (timer != null) clearTimer(timer);
      timer = null;
    },
  };
}

/** endTime của đoạn cuối không nhích qua `threshold` lần cập nhật liên tiếp → 'interrupted'; nhích lại → 'resumed'. */
export function createStaleDetector(threshold: number) {
  let last: number | null = null;
  let same = 0;
  let interrupted = false;
  return {
    update(endTime: number | null): 'interrupted' | 'resumed' | null {
      let out: 'interrupted' | 'resumed' | null = null;
      if (endTime != null && endTime === last) same++;
      else {
        same = 0;
        if (interrupted && last != null) {
          interrupted = false;
          out = 'resumed';
        }
      }
      if (!interrupted && threshold > 0 && same >= threshold && endTime != null) {
        interrupted = true;
        out = 'interrupted';
      }
      last = endTime;
      return out;
    },
  };
}

// ───────────────────────── 3. Báo lỗi CDN mỗi N ngày ─────────────────────────

/** mốc riêng của SyntaxErrorMonitor: 2010-01-01 UTC */
export const DAY_EPOCH = Date.UTC(2010, 0, 1);

export function dayNumber(nowMs: number, epoch = DAY_EPOCH) {
  return Math.floor((nowMs - epoch) / 86_400_000);
}

/**
 * Có nên gửi báo cáo không, và giá trị mới để lưu (vào cookie).
 * Bản gốc: gửi khi chưa có giá trị hoặc đã qua đủ freqDays. Thêm: giá trị lưu nằm ở "tương lai"
 * (đồng hồ máy từng chạy nhanh rồi được chỉnh lại) thì coi như chưa có, để không bị kẹt nhiều ngày.
 */
export function shouldReport(stored: string | null, nowMs: number, freqDays: number): { report: boolean; store: number; lastDay: number | null } {
  const today = dayNumber(nowMs);
  const last = stored != null && /^\d+$/.test(stored) ? Number(stored) : null;
  if (last == null || last > today || today - last >= freqDays) return { report: true, store: today, lastDay: last };
  return { report: false, store: last, lastDay: last };
}

/** Chỉ đếm SyntaxError đến từ script của chính mình (lỗi của extension hay script bên thứ ba thì bỏ). */
export function isOwnSyntaxError(e: { message?: string; filename?: string; error?: unknown }, ownOrigins: string[]) {
  const name = (e.error as { name?: string } | undefined)?.name;
  const looksSyntax = name === 'SyntaxError' || /syntaxerror/i.test(e.message ?? '');
  if (!looksSyntax) return false;
  if (!e.filename) return false;
  try {
    return ownOrigins.includes(new URL(e.filename).origin);
  } catch {
    return false;
  }
}
ts

#Đi qua code

  • backoffDelay tách công thức gốc khỏi kiểu jitter. 'fixed' giữ hành vi bản gốc để so sánh; nên dùng 'full' hoặc 'equal'.
  • createRetryState.next() trả một quyết định (retry kèm delayMs, hoặc abandon kèm lý do) thay vì gọi callback, để người gọi tự hẹn giờ và log.
  • classifyStreamError nhận attempt là số lần đã thử lại liên tiếp, bắt đầu từ 0. Lần lỗi đầu chờ FIB[0] = 0 giây: thử lại ngay, vì phần lớn lỗi mạng là thoáng qua.
  • createManifestPoller đặt attempt = 0 mỗi lần thành công, nên dãy Fibonacci chỉ áp cho lỗi liên tiếp.
  • createStaleDetector phát interrupted một lần, và chỉ phát resumed khi đã từng interrupted.
  • shouldReport coi giá trị cookie không phải số nguyên dương là chưa có, tránh Number('abc') thành NaN làm phép so sánh luôn sai.
  • isOwnSyntaxError chỉ đếm lỗi từ script có origin nằm trong danh sách, loại lỗi của extension trình duyệt (vốn cũng hiện trên window.onerror của trang).

Khác bản gốc:

  • Có full jitter và equal jitter.
  • Delay trả về được dùng thật; mã kết thúc (mặc định 410) dừng poll êm.
  • Ngày lưu ở tương lai thì báo ngay và ghi lại ngày hôm nay.
  • Lọc SyntaxError theo origin của file.
  • Không có tham số thử nghiệm A/B; truyền policy và config từ ngoài vào.

#Tại sao không làm cách khác?

  • Delay cố định (ví dụ 2 giây mãi mãi): khi server sập lâu, client tạo tải đều đặn đúng lúc server cần thở nhất. Đó chính là hành vi thực tế của player live vì bug 1.
  • Luỹ thừa không trần: delay gốc 1 giây, hệ số 2 thì lần thử thứ 21 phải chờ hơn 12 ngày. Trần 8 giây (upload) hay 34 giây (live) giữ trải nghiệm: người dùng không phải chờ vô lý sau khi mạng đã ổn.
  • Fibonacci thay vì luỹ thừa: tăng chậm hơn hệ số 2 (tỉ lệ tiến tới 1,618) và bắt đầu bằng 0, 1, 1: hai lần thử nhanh cho lỗi thoáng qua, rồi mới giãn ra. Với live, chờ quá lâu là lỡ phần đang phát, nên tăng chậm hợp lý.
  • Gửi mọi SyntaxError về server, server tự lọc: một CDN hỏng tạo ra một beacon mỗi lần tải trang của mỗi người dùng. Lọc ở client rẻ hơn nhiều, và N ngày là đủ để phát hiện xu hướng.
  • Lưu Date.now() trong localStorage thay vì số ngày trong cookie: cũng được. Cookie có ưu điểm là server đọc được ngay trong request (last_update gửi kèm cũng vậy) và dùng chung giữa các subdomain.

#Edge case và cạm bẫy

  • Retry cần idempotent. Upload chunk theo offset (bài #62) an toàn khi gửi lại; gửi tin nhắn thì không (xem bài #84 về khử trùng).
  • Full jitter có thể chờ 0 ms. Với delay nhỏ, nhiều client thử lại gần như ngay lập tức, tổng số lần thử tăng (demo: khoảng 9800 so với 7300). Equal jitter giữ được nửa delay tối thiểu.
  • Retry-After: server trả 503 hoặc 429 kèm header Retry-After thì nên ưu tiên giá trị đó hơn lịch của mình.
  • Đếm attempt theo request hay theo lỗi: bản gốc tính retryAttemptCount = số request − 1, bộ đếm request được reset khi thành công. Nếu bạn huỷ request đang bay (đổi chất lượng, người dùng tua), nhớ đừng để bộ đếm reset nhầm.
  • Mã 404 với live: đường cũ cho phép 404 tối đa live_max_try_attempts_on_404 lần rồi mới coi là lỗi, vì manifest live mới tạo có thể chưa kịp phân phối tới mọi edge của CDN.
  • Cookie bị chặn: Cookie.get luôn null thì mỗi lỗi đều là "lần đầu" và beacon bắn mỗi lần. Nên có giới hạn thứ hai trong bộ nhớ trang (một lần mỗi lần tải trang).

#Dùng trong dự án

import { createManifestPoller, createRetryState, UPLOAD_POLICY } from './retrySchedules';

const retry = createRetryState({ ...UPLOAD_POLICY, jitterMode: 'equal' });
async function send(chunk: Blob, offset: number): Promise<void> {
  try { await sendChunk(chunk, offset); retry.resetDelay(); }
  catch {
    const d = retry.next(() => navigator.onLine);
    if (d.action === 'abandon') throw new Error(`bỏ cuộc: ${d.reason}`);
    await new Promise((r) => setTimeout(r, d.delayMs));
    return send(chunk, offset);
  }
}
// live: fetch ném { code: String(res.status) } khi lỗi, trả endTime khi thành công
createManifestPoller({ periodMs: 2000, fetch: loadManifestEndTime, onEvent: (e) => e.type === 'ended' && showReplay() }).start();
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { backoffDelay, classifyStreamError, createRetryState, createStaleDetector, dayNumber, shouldReport, UPLOAD_POLICY } from './retrySchedules';

describe('backoff', () => {
  it('dãy delay giống bản gốc khi jitter = 0', () => {
    const seq = Array.from({ length: 13 }, (_, i) => backoffDelay(UPLOAD_POLICY, i + 1, () => 0.5));
    expect(seq).toEqual([500, 550, 625, 738, 906, 1159, 1539, 2109, 2963, 4244, 6167, 8000, 8000]);
  });

  it('full jitter nằm trong [0, delay]', () => {
    for (let i = 0; i < 100; i++) {
      const d = backoffDelay({ ...UPLOAD_POLICY, jitterMode: 'full' }, 12);
      expect(d).toBeGreaterThanOrEqual(0);
      expect(d).toBeLessThanOrEqual(8000);
    }
  });

  it('dừng theo tổng số lần và theo thời gian', () => {
    let t = 0;
    const s = createRetryState({ ...UPLOAD_POLICY, limits: { totalRetries: 2, totalTimeMs: 1000 } }, () => t);
    expect(s.next().action).toBe('retry');
    s.resetDelay();
    expect(s.next()).toMatchObject({ action: 'retry', delayRetry: 1, totalRetry: 2 });
    expect(s.next()).toMatchObject({ action: 'abandon', reason: 'total-retries' });
    const s2 = createRetryState(UPLOAD_POLICY, () => t);
    t = 60 * 60 * 1000;
    expect(s2.next()).toMatchObject({ action: 'abandon', reason: 'total-time' });
  });
});

describe('classifyStreamError', () => {
  it('Fibonacci cho 504, hết dãy thì bỏ cuộc, 410 kết thúc', () => {
    const delays = Array.from({ length: 11 }, (_, i) => classifyStreamError('504', i));
    expect(delays.slice(0, 10).map((d) => (d.action === 'retry' ? d.delayMs : -1))).toEqual([0, 1000, 1000, 2000, 3000, 5000, 8000, 13000, 21000, 34000]);
    expect(delays[10]).toEqual({ action: 'give-up' });
    expect(classifyStreamError('410', 0)).toEqual({ action: 'end' });
    expect(classifyStreamError('418', 0)).toEqual({ action: 'give-up' });
  });
});

it('createStaleDetector', () => {
  const s = createStaleDetector(2);
  expect([10, 10, 10, 10, 12].map((x) => s.update(x))).toEqual([null, null, 'interrupted', null, 'resumed']);
});

describe('shouldReport', () => {
  const day = (n: number) => Date.UTC(2010, 0, 1) + n * 86_400_000 + 3600_000;
  it('một lần mỗi N ngày', () => {
    expect(dayNumber(Date.UTC(2026, 9, 28))).toBe(6144);
    expect(shouldReport(null, day(100), 3)).toMatchObject({ report: true, store: 100 });
    expect(shouldReport('100', day(102), 3)).toMatchObject({ report: false });
    expect(shouldReport('100', day(103), 3)).toMatchObject({ report: true, store: 103 });
  });
  it('ngày lưu ở tương lai thì báo ngay', () => {
    expect(shouldReport('130', day(100), 3)).toMatchObject({ report: true, store: 100 });
  });
});
ts

#So với thư viện và chuẩn web

  • AWS SDK, gRPC: đều dùng backoff luỹ thừa có trần với jitter ngẫu nhiên lớn (gRPC: ±20% delay; AWS khuyến nghị full jitter). Bài "Exponential Backoff And Jitter" trên blog kiến trúc của AWS là nơi so sánh ba kiểu jitter kinh điển.
  • p-retry, async-retry (npm): cài đặt sẵn backoff có trần, có tuỳ chọn randomize. Tuỳ chọn mặc định của p-retry không bật ngẫu nhiên; nhớ bật.
  • dash.js, Shaka Player: có cấu hình retry riêng cho request manifest (số lần, khoảng chờ). Shaka dùng backoff luỹ thừa có hệ số ngẫu nhiên (fuzzFactor).
  • Reporting API, Reporting-Endpoints: trình duyệt tự gửi báo cáo lỗi CSP, deprecation, crash; không có loại báo cáo cho "script bị cắt cụt". Bắt SyntaxError vẫn là việc của trang.
  • Subresource Integrity (integrity="sha384-…"): file trên CDN bị sửa hay cắt cụt thì trình duyệt từ chối chạy, phát sự kiện error trên thẻ <script>. Phát hiện cùng loại sự cố, sớm hơn và chắc hơn bắt SyntaxError, nhưng phải biết hash lúc render HTML.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz