commit 0f6cafceac44e64be36430cb1856677b1c13df29
parent 1c11b0db9e6a7662b03aa7597a605ed83c428408
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 23:18:23 -0400
editor: e2e for the subtitle deferral, the hold and the clean probe; the fake's dl429 obeys --ignore-errors
New rate-limit.spec.ts drives the real download lane against the fake
yt-dlp: a subtitle 429 (dl429) downloads the media, defers only the
subtitles and backs nothing off, and the lane page and the video page say
so; a watch-page 429 (new wp429 sentinel, at the prefetch) on a backoff at
the cap holds youtube with the probe cadence from pacing.holdProbeMinutes,
doubles the pace, shows both, and a manual Sync is refused with the hold
sentence; a clean probe clears the hold and the backoff. pacing.spec's live
429 moves to wp429 (a subtitle 429 no longer backs the platform off);
rumble-sweep.spec's paged walk after the enumeration's 429 now paces at
2 s. The video page's deferral line lives beside VideoPanel, not among
the chore cards.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 394 insertions(+), 68 deletions(-)
diff --git a/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { subtitleDeferralAction } from "../videoActions";
+
+// THE SUBTITLE DEFERRAL, IN ONE LINE (release 17, slice RL). A video whose
+// YouTube subtitles answered HTTP 429 while its media came down: how many
+// times, when last, and until when the batch subtitle fetch (Download missing
+// subs) leaves it alone — 6 h after a strike, 7 days from the third. A
+// download from this page still fetches them. Draws nothing when there is no
+// deferral, which is every video but a handful.
+export function SubtitleDeferralLine({
+ slug,
+ videoId,
+}: {
+ slug: string;
+ videoId: string;
+}) {
+ const [d, setD] = useState<{ count: number; lastAt: number; until: number } | null>(null);
+ useEffect(() => {
+ let live = true;
+ subtitleDeferralAction(slug, videoId)
+ .then((r) => {
+ if (live) setD(r);
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, [slug, videoId]);
+ if (!d) return null;
+ const day = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
+ const held = d.count >= 3;
+ const active = d.until > Date.now();
+ return (
+ <p
+ role="note"
+ aria-label="subtitle deferral"
+ className="rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm text-warning"
+ >
+ YouTube's subtitles for this video were rate-limited (HTTP 429){" "}
+ {d.count}× — last {day(d.lastAt)}.{" "}
+ {active
+ ? held
+ ? `Left alone by Download missing subs until ${day(d.until)}.`
+ : `Download missing subs retries them after ${day(d.until)}.`
+ : "Download missing subs will retry them."}{" "}
+ A download from this page still fetches them.
+ </p>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -35,7 +35,7 @@ import { RedownloadSection } from "./cards/RedownloadSection";
import { ShortAudioBanner } from "./cards/ShortAudioBanner";
import { SourceVideoSection } from "./cards/SourceVideoSection";
import { TranscriptSourceSection } from "./cards/TranscriptSourceSection";
-import { SubtitleDeferralLine } from "./cards/SubtitleDeferralLine";
+import { SubtitleDeferralLine } from "./SubtitleDeferralLine";
import {
CANONICAL_VTT,
WHISPER_FILENAME,
diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx
@@ -1,51 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { subtitleDeferralAction } from "../../videoActions";
-
-// THE SUBTITLE DEFERRAL, IN ONE LINE (release 17, slice RL). A video whose
-// YouTube subtitles answered HTTP 429 while its media came down: how many
-// times, when last, and until when the batch subtitle fetch (Download missing
-// subs) leaves it alone — 6 h after a strike, 7 days from the third. A
-// download from this page still fetches them. Draws nothing when there is no
-// deferral, which is every video but a handful.
-export function SubtitleDeferralLine({
- slug,
- videoId,
-}: {
- slug: string;
- videoId: string;
-}) {
- const [d, setD] = useState<{ count: number; lastAt: number; until: number } | null>(null);
- useEffect(() => {
- let live = true;
- subtitleDeferralAction(slug, videoId)
- .then((r) => {
- if (live) setD(r);
- })
- .catch(() => {});
- return () => {
- live = false;
- };
- }, [slug, videoId]);
- if (!d) return null;
- const day = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
- const held = d.count >= 3;
- const active = d.until > Date.now();
- return (
- <p
- role="note"
- aria-label="subtitle deferral"
- className="rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm text-warning"
- >
- YouTube's subtitles for this video were rate-limited (HTTP 429){" "}
- {d.count}× — last {day(d.lastAt)}.{" "}
- {active
- ? held
- ? `Left alone by Download missing subs until ${day(d.until)}.`
- : `Download missing subs retries them after ${day(d.until)}.`
- : "Download missing subs will retry them."}{" "}
- A download from this page still fetches them.
- </p>
- );
-}
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -462,15 +462,26 @@ async function modeYoutubeSingleUrlManaged(url) {
`youtube-single:${url} cookies=${cookieArg()}\n`,
);
if (cookieGateBlocked(url)) failCookieGate(url);
- // `dl429`: the 2026-09-24 YouTube shape — the metadata prefetch succeeded
- // (the prefetch branch has no such sentinel), then the real download's
- // SUBTITLE fetch answers 429. Per video, not IP-wide: availability.ts
- // classifies it rate_limit, so the runner backs youtube off AND defers this
- // one video (pacing.spec.ts).
+ // `dl429`: the 2026-09-24 / 2026-10-01 YouTube shape — the metadata
+ // prefetch succeeded (the prefetch branch has no such sentinel), then the
+ // real download's SUBTITLE fetch answers 429. Per video, not IP-wide. Like
+ // the real yt-dlp: under --ignore-errors (which the managed primary passes
+ // since release 17, slice RL) the failure is a WARNING, nothing is written
+ // and the exit is clean; without it, an ERROR and exit 1. availability.ts
+ // classifies either as `subs_rate_limit`: the downloader fetches the media
+ // and the runner defers only the subtitles (rate-limit.spec.ts).
if (url.toLowerCase().includes("dl429")) {
- process.stderr.write(
- `ERROR: [youtube] ${url}: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\n`,
- );
+ const line = `[youtube] ${url}: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests`;
+ if (has("--ignore-errors")) {
+ process.stderr.write(`WARNING: ${line}\n`);
+ const id429 = urlIdYouTube(url);
+ if (!existsSync(path.join(videoDir, "metadata.info.json"))) {
+ await writeMetadata(videoDir, id429, urlSentinels(url));
+ }
+ process.stdout.write(`DLOM_ARCHIVE youtube ${id429}\n`);
+ return;
+ }
+ process.stderr.write(`ERROR: ${line}\n`);
process.exit(1);
}
process.stdout.write(`[fake-ytdlp] managed single-URL ${id}\n`);
@@ -821,6 +832,16 @@ async function main() {
`prefetch:${url} cookies=${cookieArg()}\n`,
);
if (cookieGateBlocked(url)) failCookieGate(url);
+ // `wp429`: a PLATFORM-level 429 — the watch page itself is refused. The
+ // prefetch is the first request a managed download makes, so the video
+ // stops there classified `rate_limit`: the platform backs off, its pace
+ // doubles, and the video is deferred (pacing.spec.ts, rate-limit.spec.ts).
+ if (url.toLowerCase().includes("wp429")) {
+ process.stderr.write(
+ `ERROR: [youtube] ${id}: Unable to download webpage: HTTP Error 429: Too Many Requests\n`,
+ );
+ process.exit(1);
+ }
await writeMetadata(videoDir, id, urlSentinels(url));
process.stdout.write(`[fake-ytdlp] metadata prefetch ${id}\n`);
return;
diff --git a/editor/e2e/pacing.spec.ts b/editor/e2e/pacing.spec.ts
@@ -8,7 +8,9 @@ import { baseUrl } from "./baseUrl";
// runner moves on to the NEXT video instead of re-picking the same one. The
// pure decision is unit-tested (common/jobs/unitOutcome.test.ts); this proves
// the runner's pick honours a persisted deferral, the lane says why it idles,
-// the page shows it, and a live 429 (the fake's `dl429` sentinel) defers.
+// the page shows it, and a live 429 (the fake's `wp429` sentinel — a refused
+// watch page; a SUBTITLE 429, `dl429`, no longer backs the platform off since
+// release 17 slice RL, see rate-limit.spec.ts) defers.
type Leaf = { id: string; match: { type: string; value?: string } };
type Group = { id: string; mode: string; children: (Group | Leaf)[] };
@@ -192,9 +194,9 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n
request,
}) => {
// A REAL cooldown: base 60 s ±10 %. The whole point is what happens when it
- // lapses — the runner must move on to a2, not re-pick dl429vid1.
+ // lapses — the runner must move on to a2, not re-pick wp429vid1.
test.setTimeout(150_000);
- await setup(["dl429vid1", "a2"]);
+ await setup(["wp429vid1", "a2"]);
await control(request, "start");
@@ -203,7 +205,7 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n
async () => {
const d = await persistedDownload();
return {
- deferred: Boolean(d?.videoDeferrals?.dl429vid1),
+ deferred: Boolean(d?.videoDeferrals?.wp429vid1),
fails: d?.platformBackoff.youtube?.fails ?? 0,
};
},
@@ -211,9 +213,9 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n
)
.toEqual({ deferred: true, fails: 1 });
const d = await persistedDownload();
- expect(d?.videoDeferrals?.dl429vid1?.channelSlug).toBe("alpha");
+ expect(d?.videoDeferrals?.wp429vid1?.channelSlug).toBe("alpha");
// Deferred for ~6 h, not for the cooldown's minute.
- expect(d!.videoDeferrals!.dl429vid1.until - Date.now()).toBeGreaterThan(
+ expect(d!.videoDeferrals!.wp429vid1.until - Date.now()).toBeGreaterThan(
5 * 60 * 60_000,
);
@@ -223,5 +225,5 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n
intervals: [2_000],
})
.toContain("a2");
- expect(pickOrder(await getStatus(request))).toEqual(["dl429vid1", "a2"]);
+ expect(pickOrder(await getStatus(request))).toEqual(["wp429vid1", "a2"]);
});
diff --git a/editor/e2e/rate-limit.spec.ts b/editor/e2e/rate-limit.spec.ts
@@ -0,0 +1,297 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import {
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+ writeSettings,
+} from "./helpers";
+import { baseUrl } from "./baseUrl";
+
+// A SUBTITLE 429 DOES NOT FAIL A DOWNLOAD, AND THE PACE ADAPTS (release 17,
+// slice RL). The pure decisions are unit-tested (common/jobs/unitOutcome.test.ts,
+// common/ytdlp/subtitleRateLimit.test.ts); this proves them through the real
+// download lane and the fake yt-dlp:
+// - `dl429`: the 2026-10-01 shape — the prefetch succeeds, the subtitle fetch
+// answers 429. The unit succeeds with the media and no subtitles, the
+// video's subtitles are deferred, youtube is NOT backed off, and the lane
+// page and the video page say so.
+// - `wp429`: a platform-level 429 (the watch page refused) on a backoff
+// already at the cap: youtube is HELD, its next try is a probe
+// `pacing.holdProbeMinutes` away, the pace doubled, the page shows the
+// hold and the pace, and a manual Sync is refused with the hold sentence.
+// - a probe that comes back clean clears the hold and the backoff.
+
+type Leaf = { id: string; match: { type: string; value?: string } };
+type Group = { id: string; mode: string; children: (Group | Leaf)[] };
+
+const STATE_FILE = "test-transcripts/.auto-queue/state.json";
+const TOKEN = "test-worker-token";
+const AUTH = { authorization: `Bearer ${TOKEN}` };
+
+// Copied from pacing.spec.ts: a YouTube channel with undownloaded videos.
+async function makeDownloadChannel(slug: string, ids: string[]) {
+ const root = resolvePath(`test-transcripts/channels/${slug}`);
+ await mkdir(`${root}/data`, { recursive: true });
+ await writeFile(
+ `${root}/config.json`,
+ JSON.stringify({
+ handling: "youtube",
+ name: slug,
+ url: `https://www.youtube.com/@${slug}/videos`,
+ }),
+ );
+ await writeFile(
+ `${root}/playlist`,
+ ids.map((id) => `https://www.youtube.com/watch?v=${id}`).join("\n") + "\n",
+ );
+ await writeFile(
+ `${root}/snapshot.json`,
+ JSON.stringify({
+ generatedAt: "2026-06-01T00:00:00.000Z",
+ totals: { videos: ids.length, transcribed: 0, downloaded: 0 },
+ buckets: {
+ noTranscript: [],
+ downloadedNoTranscript: [],
+ untranscoded: [],
+ multipleAudioFormats: [],
+ transcribedWithAudio: [],
+ untranscribable: [],
+ noMetadata: [],
+ failedListed: [],
+ missingFromArchive: [],
+ duplicateDirs: [],
+ partialDownloads: [],
+ corruptSource: [],
+ nonStandardVtt: [],
+ skippedByFilter: [],
+ },
+ undownloadedIds: ids,
+ }),
+ );
+}
+
+async function setup(
+ ids: string[],
+ pacing: Record<string, number> = {},
+ seed?: Record<string, unknown>,
+) {
+ await resetData(null);
+ await makeDownloadChannel("alpha", ids);
+ const root: Group = {
+ id: "root",
+ mode: "strict",
+ children: [{ id: "leaf-alpha", match: { type: "channel", value: "alpha" } }],
+ };
+ // Settings first: writing them clears the server's shared auto-queue state,
+ // so the seed below is what the runner reads when it starts.
+ await writeSettings({
+ adminTitle: "Test Admin",
+ maxTranscriptPageBytes: 8388608,
+ sleepBetweenDownloadsSeconds: 0,
+ minFreeDiskGB: 0,
+ pacing,
+ autoQueue: {
+ transcription: {},
+ download: { enabled: true, maxWorkers: null, root },
+ },
+ });
+ if (seed) {
+ await mkdir(resolvePath("test-transcripts/.auto-queue"), { recursive: true });
+ await writeFile(
+ resolvePath(STATE_FILE),
+ JSON.stringify({
+ transcription: { runtime: {}, picks: [], platformBackoff: {} },
+ download: { runtime: {}, picks: [], platformBackoff: {}, ...seed },
+ }),
+ );
+ }
+}
+
+type Persisted = {
+ platformBackoff: Record<string, { until: number; fails: number }>;
+ videoDeferrals?: Record<string, { until: number; channelSlug: string }>;
+ platformPace?: Record<string, { sleepRequestsSeconds: number; baseSeconds: number }>;
+ platformHolds?: Record<string, { since: number; probeAt: number }>;
+ subtitleDeferrals?: Record<string, { count: number; until: number; channelSlug: string }>;
+};
+
+async function persisted(): Promise<Persisted | null> {
+ try {
+ return (await readJson<{ download: Persisted }>(STATE_FILE)).download;
+ } catch {
+ return null;
+ }
+}
+
+type Status = {
+ download: {
+ runner: { running: boolean; idleReason: string | null };
+ picks: { videoId: string }[];
+ };
+};
+
+async function getStatus(
+ request: import("@playwright/test").APIRequestContext,
+): Promise<Status> {
+ const res = await request.get(`${baseUrl}/api/auto-queue/status`);
+ expect(res.ok()).toBeTruthy();
+ return res.json();
+}
+
+async function control(
+ request: import("@playwright/test").APIRequestContext,
+ action: "start" | "stop",
+) {
+ const res = await request.post(`${baseUrl}/api/auto-queue/control`, {
+ data: { kind: "download", action },
+ });
+ if (action === "start") expect(res.ok()).toBeTruthy();
+}
+
+test.afterEach(async ({ request }) => {
+ await control(request, "stop");
+});
+
+test("a subtitle 429 downloads the media, defers only the subtitles, and backs nothing off", async ({
+ page,
+ request,
+}) => {
+ await setup(["dl429sub1"]);
+ await control(request, "start");
+
+ await expect
+ .poll(async () => (await persisted())?.subtitleDeferrals?.dl429sub1?.count ?? 0, {
+ timeout: 60_000,
+ })
+ .toBe(1);
+ const d = await persisted();
+ // The platform was not backed off, its pace was not raised, and the video
+ // itself is not deferred — its media is done.
+ expect(d?.platformBackoff.youtube).toBeUndefined();
+ expect(d?.platformPace ?? {}).toEqual({});
+ expect(d?.videoDeferrals ?? {}).toEqual({});
+ expect(d?.subtitleDeferrals?.dl429sub1?.channelSlug).toBe("alpha");
+
+ // The media is on disk, no subtitles, and the outcome is a success that says why.
+ const dir = "test-transcripts/channels/alpha/data/dl429sub1";
+ expect(await pathExists(`${dir}/audio.mp3`)).toBe(true);
+ expect(await pathExists(`${dir}/transcript.en.vtt`)).toBe(false);
+ const outcome = await readJson<{ status: string; failureClass?: string }>(
+ `${dir}/download-outcome.json`,
+ );
+ expect(outcome.status).toBe("ok");
+ expect(outcome.failureClass).toBe("subs_rate_limit");
+
+ await page.goto("/operations/download");
+ const region = page.getByRole("region", { name: "Rate-limit cooldown" });
+ await expect(region).toBeVisible({ timeout: 20_000 });
+ const subs = region.getByRole("list", { name: "Deferred subtitles" });
+ await expect(subs).toContainText("alpha/dl429sub1");
+ await expect(subs).toContainText("1×");
+ await expect(region.getByRole("list", { name: "Platforms in cooldown" })).toHaveCount(0);
+ await expect(region.getByRole("list", { name: "Platforms held" })).toHaveCount(0);
+
+ await page.goto("/channels/alpha/videos/dl429sub1");
+ await expect(page.getByRole("note", { name: "subtitle deferral" })).toContainText(
+ /rate-limited \(HTTP 429\) 1×/,
+ { timeout: 20_000 },
+ );
+});
+
+test("a platform 429 at the cap holds youtube: one probe per holdProbeMinutes, the pace doubled, a manual Sync refused", async ({
+ page,
+ request,
+}) => {
+ // fails: 5 lapsed — the next failure is the first AT the 30-minute cap, and
+ // with holdAfterFailsAtCap 1 that one holds the platform.
+ await setup(
+ ["wp429held", "a2"],
+ { holdAfterFailsAtCap: 1, holdProbeMinutes: 7 },
+ { platformBackoff: { youtube: { until: Date.now() - 1_000, fails: 5 } } },
+ );
+ await control(request, "start");
+
+ await expect
+ .poll(async () => Boolean((await persisted())?.platformHolds?.youtube), {
+ timeout: 60_000,
+ })
+ .toBe(true);
+ const d = (await persisted())!;
+ expect(d.platformBackoff.youtube.fails).toBe(6);
+ // The probe cadence is the setting's, not the 30-minute cap's.
+ const left = d.platformBackoff.youtube.until - Date.now();
+ expect(left).toBeGreaterThan(6 * 60_000);
+ expect(left).toBeLessThanOrEqual(7 * 60_000);
+ expect(d.platformHolds!.youtube.probeAt).toBe(d.platformBackoff.youtube.until);
+ // A rate limit doubled the pace.
+ expect(d.platformPace?.youtube?.sleepRequestsSeconds).toBe(2);
+
+ // a2 is pending on a held platform: the lane says it is held.
+ await expect
+ .poll(async () => (await getStatus(request)).download.runner.idleReason, {
+ timeout: 30_000,
+ })
+ .toBe("held");
+ expect((await getStatus(request)).download.picks.map((p) => p.videoId)).toEqual([
+ "wp429held",
+ ]);
+
+ await page.goto("/operations/download");
+ const region = page.getByRole("region", { name: "Rate-limit cooldown" });
+ await expect(region).toBeVisible({ timeout: 20_000 });
+ const held = region.getByRole("list", { name: "Platforms held" });
+ await expect(held).toContainText("youtube");
+ await expect(held).toContainText(/next probe in (6m|7m)/);
+ await expect(held).toContainText("6 failures in a row");
+ await expect(region.getByRole("list", { name: "Platforms in cooldown" })).toHaveCount(0);
+ await expect(region.getByRole("list", { name: "Request pace" })).toContainText(
+ "2s between requests (base 1s)",
+ );
+ await expect(
+ page
+ .getByText(/every pending platform is held after repeated rate limits/)
+ .first(),
+ ).toBeVisible();
+
+ // A manual Sync is refused with the hold, naming the next probe.
+ const sync = await request.post(`${baseUrl}/api/ops/sync`, {
+ headers: AUTH,
+ data: { slug: "alpha" },
+ });
+ expect(sync.status()).toBe(400);
+ const body = (await sync.json()) as { error: string; info?: boolean };
+ expect(body.info).toBe(true);
+ expect(body.error).toMatch(
+ /^youtube is held: its rate limit outlasted the cooldown cap \(6 failures in a row, held since \d\d:\d\d UTC\)\. Auto-download probes it once every 7 min — the next probe is in [67] min\. Sync will run once a probe comes back clean\.$/,
+ );
+});
+
+test("a probe that comes back clean clears the hold and the backoff", async ({
+ request,
+}) => {
+ const now = Date.now();
+ await setup(["a1"], { holdProbeMinutes: 60 }, {
+ platformBackoff: { youtube: { until: now - 1_000, fails: 9 } },
+ platformHolds: { youtube: { since: now - 3_600_000, probeAt: now - 1_000 } },
+ });
+ await control(request, "start");
+
+ await expect
+ .poll(
+ async () => {
+ const d = await persisted();
+ return {
+ held: Boolean(d?.platformHolds?.youtube),
+ backoff: Boolean(d?.platformBackoff.youtube),
+ };
+ },
+ { timeout: 60_000 },
+ )
+ .toEqual({ held: false, backoff: false });
+ expect((await getStatus(request)).download.picks.map((p) => p.videoId)).toEqual(["a1"]);
+ expect(await pathExists("test-transcripts/channels/alpha/data/a1/transcript.en.vtt")).toBe(
+ true,
+ );
+});
diff --git a/editor/e2e/rumble-sweep.spec.ts b/editor/e2e/rumble-sweep.spec.ts
@@ -46,6 +46,7 @@ type ChannelConfigFile = {
type AutoQueueStateFile = {
download: {
platformBackoff: Record<string, { until: number; fails: number }>;
+ platformPace?: Record<string, { sleepRequestsSeconds: number }>;
};
};
@@ -133,9 +134,13 @@ test("a Rumble sweep that 429s mid-listing is incomplete: paged walk, cooldown,
expect(paged.length).toBeGreaterThan(0);
for (const line of [...full, ...paged]) {
expect(line).toContain("--impersonate chrome");
- expect(line).toContain("--sleep-requests 1");
expect(line).toContain(RUMBLE_URL);
}
+ // The pace adapts (release 17, slice RL): the enumeration ran at rumble's
+ // fixed 1 s, its 429 doubled the pace, and the paged walk that followed it
+ // paced at 2 s.
+ for (const line of full) expect(line).toContain("--sleep-requests 1");
+ for (const line of paged) expect(line).toContain("--sleep-requests 2");
// The rumble cooldown is recorded where the Sync gate and the runner read it.
const state = await readJson<AutoQueueStateFile>(
@@ -144,6 +149,7 @@ test("a Rumble sweep that 429s mid-listing is incomplete: paged walk, cooldown,
expect(state.download.platformBackoff.rumble?.until).toBeGreaterThan(
Date.now(),
);
+ expect(state.download.platformPace?.rumble?.sleepRequestsSeconds).toBe(2);
// Not a listing: no sweep stamp, stored playlist untouched.
const after = await readConfig();