fix(studio): a clip that cannot be read gives up after two retries with one typed line (#6477)

* fix(studio): a clip that cannot be read gives up after two retries with one typed line

* fix(studio): a clip whose stream breaks mid-read also fails with the typed line
This commit is contained in:
Miguel Ángel
2026-10-26 21:08:46 +00:00
committed by GitHub
parent 08d3745391
commit dfd2028f16
6 changed files with 313 additions and 5 deletions
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { MediaFetchError, mediaUrlSourceOptions, reportMediaFetchFailure } from "./mediaFetch";
const URL_WITH_PATH = "http://127.0.0.1:5190/api/projects/demo/preview/clips/take-1.mp4";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("mediaUrlSourceOptions", () => {
it("turns a network failure into a typed error without the URL", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const { fetchFn } = mediaUrlSourceOptions("probe");
const error = await fetchFn(URL_WITH_PATH).catch((e: unknown) => e);
expect(error).toBeInstanceOf(MediaFetchError);
expect(error).toMatchObject({ reader: "probe", status: null, code: "TypeError" });
expect(String((error as Error).message)).not.toContain("take-1");
});
it("names the network code when the failure carries one", async () => {
const failure = Object.assign(new TypeError("fetch failed"), { cause: { code: "EBUSY" } });
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(failure));
const error = await mediaUrlSourceOptions("thumbnail")
.fetchFn(URL_WITH_PATH)
.catch((e) => e);
expect(error).toMatchObject({ reader: "thumbnail", code: "EBUSY" });
});
it("turns an HTTP error status into a typed error", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 403 })));
const error = await mediaUrlSourceOptions("probe")
.fetchFn(URL_WITH_PATH)
.catch((e) => e);
expect(error).toMatchObject({
status: 403,
message: "probe range fetch failed: HTTP 403 after 0 retries",
});
});
it("lets a cancellation through untouched", async () => {
const abort = new DOMException("Aborted", "AbortError");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abort));
await expect(mediaUrlSourceOptions("probe").fetchFn(URL_WITH_PATH)).rejects.toBe(abort);
});
it("retries a network failure twice, then gives up and records the retries", () => {
const { getRetryDelay } = mediaUrlSourceOptions("probe");
const failure = new MediaFetchError("probe", null, "TypeError");
expect([1, 2, 3].map((attempt) => getRetryDelay(attempt, failure))).toEqual([0.5, 1, null]);
expect(failure).toMatchObject({
retries: 2,
message: "probe range fetch failed: network TypeError after 2 retries",
});
});
it("gives up on a body that breaks mid-read with the typed error, through a real mediabunny read", async () => {
const brokenBody = () =>
new Response(
new ReadableStream({
pull: (controller) => controller.error(new TypeError("network error")),
}),
{
status: 206,
headers: { "Content-Range": "bytes 0-65535/1000000", "Content-Length": "65536" },
},
);
vi.stubGlobal(
"fetch",
vi.fn(async () => brokenBody()),
);
vi.useFakeTimers();
const mb = await import("mediabunny");
const runnerListeners = process.listeners("unhandledRejection");
const unhandled: unknown[] = [];
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (reason) => unhandled.push(reason));
try {
const input = new mb.Input({
source: new mb.UrlSource(URL_WITH_PATH, mediaUrlSourceOptions("thumbnail")),
formats: mb.ALL_FORMATS,
});
const read = input.getDurationFromMetadata().catch((e: unknown) => e);
await vi.runAllTimersAsync();
const error = await read;
input.dispose();
await vi.runAllTimersAsync();
expect(error).toMatchObject({
name: "MediaFetchError",
message: "thumbnail range fetch failed: network TypeError after 2 retries",
});
for (const reason of unhandled) expect(reason).toBeInstanceOf(MediaFetchError);
} finally {
vi.useRealTimers();
process.removeAllListeners("unhandledRejection");
for (const listener of runnerListeners) process.on("unhandledRejection", listener);
}
});
it("counts mid-stream read failures, which always arrive as attempt 1", () => {
const { getRetryDelay } = mediaUrlSourceOptions("thumbnail");
const readError = new TypeError("network error");
expect([1, 1, 1].map((attempt) => getRetryDelay(attempt, readError))).toEqual([0.5, 1, null]);
});
it("does not retry a client error status", () => {
const failure = new MediaFetchError("probe", 404, null);
expect(mediaUrlSourceOptions("probe").getRetryDelay(1, failure)).toBeNull();
expect(failure.retries).toBe(0);
});
// mediabunny rethrows a failed read-ahead nobody awaits "so it doesn't get swallowed": that copy is typed too.
it("fails a real mediabunny read, and its unawaited read-ahead, with the typed error", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 404 })));
const mb = await import("mediabunny");
const runnerListeners = process.listeners("unhandledRejection");
const unhandled: unknown[] = [];
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (reason) => unhandled.push(reason));
try {
const input = new mb.Input({
source: new mb.UrlSource(URL_WITH_PATH, mediaUrlSourceOptions("probe")),
formats: mb.ALL_FORMATS,
});
const error = await input.getDurationFromMetadata().catch((e: unknown) => e);
input.dispose();
await new Promise(setImmediate);
expect(error).toMatchObject({ name: "MediaFetchError", status: 404, retries: 0 });
expect(unhandled.length).toBeGreaterThan(0);
for (const reason of unhandled) expect(reason).toBeInstanceOf(MediaFetchError);
} finally {
process.removeAllListeners("unhandledRejection");
for (const listener of runnerListeners) process.on("unhandledRejection", listener);
}
});
});
describe("reportMediaFetchFailure", () => {
it("writes one scrubbed line for a typed failure and nothing for anything else", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failure = new MediaFetchError("thumbnail", null, "TypeError");
failure.setRetries(2);
reportMediaFetchFailure(failure);
reportMediaFetchFailure(new Error("Video source has no decodable video track"));
expect(warn.mock.calls).toEqual([
["[Studio] thumbnail range fetch failed: network TypeError after 2 retries"],
]);
});
});
@@ -0,0 +1,98 @@
export type MediaReader = "probe" | "thumbnail";
const describe = (
reader: MediaReader,
status: number | null,
code: string | null,
retries: number,
) =>
`${reader} range fetch failed: ${status === null ? `network ${code}` : `HTTP ${status}`} after ${retries} retries`;
/** A media range fetch that gave up: which reader, the HTTP status or network code, and the retries. Never the URL. */
export class MediaFetchError extends Error {
override readonly name = "MediaFetchError";
retries = 0;
constructor(
readonly reader: MediaReader,
readonly status: number | null,
readonly code: string | null,
) {
super(describe(reader, status, code, 0));
}
setRetries(retries: number): void {
this.retries = retries;
this.message = describe(this.reader, this.status, this.code, retries);
}
}
const MAX_RETRIES = 2;
const isAbort = (error: unknown) => (error as { name?: unknown } | null)?.name === "AbortError";
function networkCode(error: unknown): string {
const cause = (error as { cause?: { code?: unknown } } | null)?.cause;
if (typeof cause?.code === "string") return cause.code;
const name = (error as { name?: unknown } | null)?.name;
return typeof name === "string" ? name : "unknown";
}
const typedFailure = (reader: MediaReader, error: unknown) =>
isAbort(error) ? error : new MediaFetchError(reader, null, networkCode(error));
/** The body, failing a broken read with a MediaFetchError: mediabunny rethrows a body error as-is. */
function typedBody(
reader: MediaReader,
body: ReadableStream<Uint8Array>,
): ReadableStream<Uint8Array> {
const source = body.getReader();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await source.read();
if (done) controller.close();
else controller.enqueue(value);
} catch (error) {
controller.error(typedFailure(reader, error));
}
},
cancel: (reason) => source.cancel(reason),
});
}
/**
* Options for a mediabunny `UrlSource`. Its default retries a same-origin network failure
* forever and logs a bare `TypeError` each time; these retry twice and fail with a MediaFetchError.
*/
export function mediaUrlSourceOptions(reader: MediaReader) {
let failures = 0;
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let response: Response;
try {
// oxlint-disable-next-line no-restricted-globals -- a media read, on the media's sockets like mediabunny's own
response = await fetch(input, init);
} catch (error) {
throw typedFailure(reader, error);
}
if (!response.ok) throw new MediaFetchError(reader, response.status, null);
if (!response.body) return response;
const { status, statusText, headers } = response;
return new Response(typedBody(reader, response.body), { status, statusText, headers });
};
// Counted per source: mediabunny passes 1 for every failed mid-stream read.
const getRetryDelay = (_previousAttempts: number, error: unknown): number | null => {
const clientError =
error instanceof MediaFetchError && error.status !== null && error.status < 500;
if (error instanceof MediaFetchError) error.setRetries(failures);
if (clientError || failures >= MAX_RETRIES) return null;
failures++;
return 0.5 * failures;
};
return { fetchFn, getRetryDelay };
}
export function reportMediaFetchFailure(error: unknown): void {
if (!(error instanceof MediaFetchError)) return;
console.warn(`[Studio] ${error.message}`);
}
@@ -8,16 +8,22 @@ import {
probeMissingSourceDurations,
resetMediaProbeRegistry,
} from "./mediaProbe";
import { MediaFetchError } from "./mediaFetch";
const dispose = vi.fn();
const getDurationFromMetadata = vi.fn(async () => 5);
const requestedSources: string[] = [];
const sourceRetryRules: string[] = [];
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {
constructor(
readonly url: string,
readonly options?: { getRetryDelay?: unknown },
) {
requestedSources.push(url);
sourceRetryRules.push(typeof options?.getRetryDelay);
}
},
Input: class {
@@ -32,6 +38,7 @@ beforeEach(() => {
resetMediaProbeRegistry();
vi.clearAllMocks();
requestedSources.length = 0;
sourceRetryRules.length = 0;
getDurationFromMetadata.mockResolvedValue(5);
});
@@ -40,6 +47,19 @@ afterEach(() => {
});
describe("media probe registry", () => {
it("reports a failed range fetch as one typed line and records the probe as failed", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const failure = new MediaFetchError("probe", 403, null);
getDurationFromMetadata.mockRejectedValue(failure);
await expect(probeMediaUrl("/locked.mp4")).resolves.toBeNull();
expect(sourceRetryRules).toEqual(["function"]);
expect(warn.mock.calls).toEqual([
["[Studio] probe range fetch failed: HTTP 403 after 0 retries"],
]);
warn.mockRestore();
});
it("deduplicates and caches successful probes", async () => {
const [first, second] = await Promise.all([
probeMediaUrl("/video.mp4"),
+4 -2
View File
@@ -1,5 +1,6 @@
import { authoredSrcPath, resolveMediaPreviewUrl } from "../components/thumbnailUtils";
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
import { mediaUrlSourceOptions, reportMediaFetchFailure } from "./mediaFetch";
export interface MediaProbeResult {
duration: number;
@@ -55,7 +56,7 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
if (!mb) return null;
const input = new mb.Input({
source: new mb.UrlSource(url),
source: new mb.UrlSource(url, mediaUrlSourceOptions("probe")),
formats: mb.ALL_FORMATS,
});
try {
@@ -73,7 +74,8 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
hasAudio: audioTracks.length > 0,
};
return result;
} catch {
} catch (error) {
reportMediaFetchFailure(error);
return null;
} finally {
input.dispose();
@@ -2,8 +2,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideoDecoder";
import { MediaFetchError } from "./mediaFetch";
const dispose = vi.fn();
const sourceRetryRules: string[] = [];
const canvasesAtTimestamps = vi.fn();
const getKeyPacket = vi.fn(async (_time: number) => null as { timestamp: number } | null);
const input = {
@@ -14,7 +16,12 @@ const input = {
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {}
constructor(
readonly url: string,
readonly options?: { getRetryDelay?: unknown },
) {
sourceRetryRules.push(typeof options?.getRetryDelay);
}
},
Input: class {
getPrimaryVideoTrack = input.getPrimaryVideoTrack;
@@ -371,6 +378,22 @@ describe("decodeVideoThumbnail", () => {
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(5);
});
it("reports a failed range fetch as one typed line and rethrows it", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
sourceRetryRules.length = 0;
input.getPrimaryVideoTrack.mockRejectedValue(
new MediaFetchError("thumbnail", null, "TypeError"),
);
await expect(
decodeVideoThumbnail({ source: "/locked.mp4", frameCount: 1 }, new AbortController().signal),
).rejects.toBeInstanceOf(MediaFetchError);
expect(sourceRetryRules).toEqual(["function"]);
expect(warn.mock.calls).toEqual([
["[Studio] thumbnail range fetch failed: network TypeError after 0 retries"],
]);
warn.mockRestore();
});
it("releases input and degrades when the source has no video track", async () => {
input.getPrimaryVideoTrack.mockResolvedValue(null);
await expect(
@@ -4,6 +4,7 @@ import {
type ThumbnailLoadedResult,
type ThumbnailValue,
} from "./thumbnailScheduler";
import { mediaUrlSourceOptions, reportMediaFetchFailure } from "./mediaFetch";
export interface VideoThumbnailDecodeRequest {
source: string;
@@ -260,6 +261,7 @@ export async function decodeVideoThumbnail(
return await decodeMissingFrames(request, signal, budgets, keyOf, resources);
} catch (error) {
releaseDecodedResources(resources);
reportMediaFetchFailure(error);
throw error;
}
}
@@ -275,7 +277,7 @@ async function decodeMissingFrames(
throwIfAborted(signal);
const input = new mediabunny.Input({
source: new mediabunny.UrlSource(request.source),
source: new mediabunny.UrlSource(request.source, mediaUrlSourceOptions("thumbnail")),
formats: mediabunny.ALL_FORMATS,
});
try {