import { test } from "node:test"; import assert from "node:assert/strict"; import { BACKOFF_BASE_MS, BACKOFF_MAX_MS, type PlatformBackoffState, clearBackoff, coercePlatformBackoff, isCoolingDown, mergeBackoffEntry, nextBackoff, pruneExpired, VIDEO_RATE_LIMIT_DEFER_MS, type VideoDeferralState, coerceVideoDeferrals, deferVideo, isVideoDeferred, pruneDeferred, PACING_DEFAULTS, downloadGapMs, failsAtCap, FAILS_TO_REACH_CAP, heldPlatformSentence, prunePlatformPacing, } from "./platformBackoff"; import { sanitizePacing } from "../lib/settingsSchema"; // Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test common/jobs/platformBackoff.test.ts // Deterministic jitter: no jitter (returns 0.5 -> factor 1.0). const noJitter = () => 0.5; test("nextBackoff doubles the cooldown across consecutive failures", () => { const now = 1_000_000; const first = nextBackoff(undefined, now, noJitter); assert.equal(first.fails, 1); assert.equal(first.until - now, BACKOFF_BASE_MS); // 60s const second = nextBackoff(first, now, noJitter); assert.equal(second.fails, 2); assert.equal(second.until - now, BACKOFF_BASE_MS * 2); // 120s const third = nextBackoff(second, now, noJitter); assert.equal(third.fails, 3); assert.equal(third.until - now, BACKOFF_BASE_MS * 4); // 240s }); test("nextBackoff caps the cooldown at BACKOFF_MAX_MS", () => { const now = 0; let entry = nextBackoff(undefined, now, noJitter); for (let i = 0; i < 20; i++) entry = nextBackoff(entry, now, noJitter); assert.equal(entry.until - now, BACKOFF_MAX_MS); assert.ok(entry.fails > 1); }); test("nextBackoff applies bounded jitter (±10%)", () => { const now = 0; const low = nextBackoff(undefined, now, () => 0); // factor 0.9 const high = nextBackoff(undefined, now, () => 1); // factor 1.1 assert.equal(low.until, Math.round(BACKOFF_BASE_MS * 0.9)); assert.equal(high.until, Math.round(BACKOFF_BASE_MS * 1.1)); }); test("E2E_BACKOFF_BASE_MS shortens the base; the cap and the hold arithmetic keep the real constants", () => { const saved = process.env.E2E_BACKOFF_BASE_MS; try { process.env.E2E_BACKOFF_BASE_MS = "20000"; const first = nextBackoff(undefined, 0, noJitter); assert.equal(first.until, 20_000); assert.equal(nextBackoff(first, 0, noJitter).until, 40_000); let entry = first; for (let i = 0; i < 20; i++) entry = nextBackoff(entry, 0, noJitter); assert.equal(entry.until, BACKOFF_MAX_MS); assert.equal(failsAtCap(FAILS_TO_REACH_CAP), 1); process.env.E2E_BACKOFF_BASE_MS = "not a number"; assert.equal(nextBackoff(undefined, 0, noJitter).until, BACKOFF_BASE_MS); } finally { if (saved === undefined) delete process.env.E2E_BACKOFF_BASE_MS; else process.env.E2E_BACKOFF_BASE_MS = saved; } }); test("isCoolingDown reflects the until window", () => { const state: PlatformBackoffState = { odysee: { until: 500, fails: 1 } }; assert.equal(isCoolingDown(state, "odysee", 499), true); assert.equal(isCoolingDown(state, "odysee", 500), false); // boundary: until is exclusive assert.equal(isCoolingDown(state, "odysee", 501), false); assert.equal(isCoolingDown(state, "youtube", 0), false); // absent platform }); test("clearBackoff removes a platform (success resets escalation)", () => { const state: PlatformBackoffState = { odysee: { until: 999, fails: 3 } }; clearBackoff(state, "odysee"); assert.deepEqual(state, {}); // Next failure starts over at attempt 1. const fresh = nextBackoff(state["odysee"], 0, noJitter); assert.equal(fresh.fails, 1); }); test("pruneExpired drops only long-lapsed entries, keeps active/recent ones", () => { const now = 10_000_000; const state: PlatformBackoffState = { active: { until: now + 1000, fails: 1 }, // still cooling down recent: { until: now - 1000, fails: 2 }, // lapsed but within retain window old: { until: now - BACKOFF_MAX_MS - 1, fails: 5 }, // long lapsed }; pruneExpired(state, now); assert.ok(state.active, "active kept"); assert.ok(state.recent, "recently-lapsed kept for escalation memory"); assert.equal(state.old, undefined, "long-lapsed dropped"); }); test("coercePlatformBackoff tolerates corrupt/missing shapes", () => { assert.deepEqual(coercePlatformBackoff(undefined), {}); assert.deepEqual(coercePlatformBackoff("nope"), {}); assert.deepEqual(coercePlatformBackoff({ odysee: { until: 5 } }), {}); // missing fails assert.deepEqual(coercePlatformBackoff({ odysee: { until: "x", fails: 1 } }), {}); assert.deepEqual( coercePlatformBackoff({ odysee: { until: 5, fails: 2 }, junk: 42, youtube: { until: 7, fails: 1, extra: "ignored" }, }), { odysee: { until: 5, fails: 2 }, youtube: { until: 7, fails: 1 } }, ); }); test("deferVideo defers a video for 6 h by default, keyed by id", () => { const state: VideoDeferralState = {}; deferVideo(state, "v1", "alpha", 1_000); assert.deepEqual(state, { v1: { until: 1_000 + VIDEO_RATE_LIMIT_DEFER_MS, channelSlug: "alpha" }, }); assert.equal(VIDEO_RATE_LIMIT_DEFER_MS, 6 * 60 * 60_000); }); test("deferVideo re-defers from now (a later 429 extends the window)", () => { const state: VideoDeferralState = {}; deferVideo(state, "v1", "alpha", 1_000, 10); deferVideo(state, "v1", "alpha", 5_000, 10); assert.equal(state.v1.until, 5_010); }); test("isVideoDeferred reflects the until window, per video", () => { const state: VideoDeferralState = { v1: { until: 2_000, channelSlug: "a" } }; assert.equal(isVideoDeferred(state, "v1", 1_999), true); assert.equal(isVideoDeferred(state, "v1", 2_000), false); assert.equal(isVideoDeferred(state, "v2", 1_000), false); }); test("pruneDeferred drops lapsed deferrals only", () => { const state: VideoDeferralState = { old: { until: 1_000, channelSlug: "a" }, edge: { until: 2_000, channelSlug: "a" }, live: { until: 3_000, channelSlug: "b" }, }; pruneDeferred(state, 2_000); assert.deepEqual(Object.keys(state), ["live"]); }); test("coerceVideoDeferrals tolerates corrupt/missing shapes", () => { assert.deepEqual(coerceVideoDeferrals(undefined), {}); assert.deepEqual(coerceVideoDeferrals("nope"), {}); assert.deepEqual( coerceVideoDeferrals({ ok: { until: 5, channelSlug: "a" }, noSlug: { until: 5 }, badUntil: { until: "5", channelSlug: "a" }, inf: { until: Infinity, channelSlug: "a" }, nul: null, }), { ok: { until: 5, channelSlug: "a" } }, ); }); // Release 8 review, S: the two records merge field by field. test("mergeBackoffEntry takes the max of until and of fails separately", () => { assert.deepEqual(mergeBackoffEntry(undefined, { until: 5, fails: 2 }), { until: 5, fails: 2, }); // A short manual cooldown on disk with a low count must not erase the live // escalation, and a later disk `until` must not be lost either. assert.deepEqual( mergeBackoffEntry({ until: 100, fails: 13 }, { until: 200, fails: 1 }), { until: 200, fails: 13 }, ); assert.deepEqual( mergeBackoffEntry({ until: 300, fails: 1 }, { until: 200, fails: 7 }), { until: 300, fails: 7 }, ); }); // ── release 17, slice RL ───────────────────────────────────────────────────── test("PACING_DEFAULTS are the settings schema's defaults", () => { assert.deepEqual({ ...PACING_DEFAULTS }, sanitizePacing({})); assert.deepEqual(sanitizePacing({ sleepRequestsCapSeconds: 999, holdProbeMinutes: 0 }), { ...PACING_DEFAULTS, sleepRequestsCapSeconds: 120, holdProbeMinutes: 1, }); }); test("the cap is reached at the sixth failure; failsAtCap counts from there", () => { assert.equal(FAILS_TO_REACH_CAP, 6); assert.equal(BACKOFF_BASE_MS * 2 ** (FAILS_TO_REACH_CAP - 2) < BACKOFF_MAX_MS, true); assert.deepEqual([5, 6, 7, 8].map(failsAtCap), [0, 1, 2, 3]); }); test("the lane gap is the operator's sleep plus the pace above its base", () => { assert.equal(downloadGapMs(0, 1, 1), 0); assert.equal(downloadGapMs(30, 1, 1), 30_000); assert.equal(downloadGapMs(30, 4, 1), 33_000); assert.equal(downloadGapMs(0, 8, 1), 7_000); assert.equal(downloadGapMs(10, 2, 0), 12_000); assert.equal(downloadGapMs(-5, 0.5, 1), 0); }); test("a platform floor raises the operator's sleep to it and jitters it by up to half again", () => { // Below the floor: the floor, stretched by random * 50 %. assert.equal(downloadGapMs(0, 3, 3, { minSeconds: 60, random: 0 }), 60_000); assert.equal(downloadGapMs(30, 3, 3, { minSeconds: 60, random: 0.5 }), 75_000); assert.equal(downloadGapMs(30, 3, 3, { minSeconds: 60, random: 1 }), 90_000); // Above the floor the operator's sleep is the base; a raised pace still adds. assert.equal(downloadGapMs(120, 3, 3, { minSeconds: 60, random: 0 }), 120_000); assert.equal(downloadGapMs(0, 6, 3, { minSeconds: 60, random: 0 }), 63_000); // Out-of-range randoms are clamped; an absent random still lands in range. assert.equal(downloadGapMs(0, 3, 3, { minSeconds: 60, random: 7 }), 90_000); const g = downloadGapMs(0, 3, 3, { minSeconds: 60 }); assert.ok(g >= 60_000 && g <= 90_000, String(g)); // No floor: exactly the old gap, never jittered. assert.equal(downloadGapMs(30, 1, 1, { minSeconds: 0, random: 1 }), 30_000); assert.equal(downloadGapMs(30, 1, 1, {}), 30_000); }); test("a held platform's backoff survives the prune; a hold with no backoff is dropped", () => { const now = 10 * BACKOFF_MAX_MS; const state = { platformBackoff: { youtube: { until: 0, fails: 9 }, rumble: { until: 0, fails: 2 }, } as PlatformBackoffState, platformPace: {}, platformHolds: { youtube: { since: 0, probeAt: 0, rateLimited: true }, odysee: { since: 0, probeAt: 0, rateLimited: true }, }, }; prunePlatformPacing(state, now); assert.deepEqual(Object.keys(state.platformBackoff), ["youtube"]); assert.deepEqual(Object.keys(state.platformHolds), ["youtube"]); }); test("the held sentence names the hold and the next probe", () => { const since = Date.UTC(2026, 9, 1, 14, 2); assert.equal( heldPlatformSentence("youtube", { since, probeAt: since + 42 * 60_000, rateLimited: true }, 8, since, { probeMinutes: 60, what: "Sync", }), "youtube is held: its rate limit outlasted the cooldown cap (8 failures in a row, held since 14:02 UTC). " + "Auto-download probes it once every 60 min — the next probe is in 42 min. Sync will run once a probe comes back clean.", ); });