mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix(producer): transport safe extraction failure metadata (#3592)
* fix(producer): transport safe extraction failure metadata * refactor(producer): generalize public error metadata * test(producer): use vendor-neutral media hosts
This commit is contained in:
@@ -197,6 +197,7 @@ export {
|
||||
classifyVideoExtractionError,
|
||||
isVideoSourceExtractionError,
|
||||
runVideoExtractionWithRetry,
|
||||
safeVideoExtractionSourceIdentity,
|
||||
VideoSourceExtractionError,
|
||||
type VideoElement,
|
||||
type ImageElement,
|
||||
@@ -207,6 +208,10 @@ export {
|
||||
type TimelineExtractionWindow,
|
||||
type VideoExtractionFailure,
|
||||
type VideoExtractionFailureKind,
|
||||
type VideoExtractionFailureGroupDetails,
|
||||
type VideoExtractionFailureRetry,
|
||||
type VideoExtractionFailureStatusClass,
|
||||
type SafeVideoExtractionSourceIdentity,
|
||||
type VideoFrameFormat,
|
||||
VIDEO_FRAME_FORMATS,
|
||||
isVideoFrameFormat,
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UrlDownloadError } from "../utils/urlDownloader.js";
|
||||
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { safeDownloadUrlIdentity, UrlDownloadError } from "../utils/urlDownloader.js";
|
||||
import {
|
||||
classifyFfmpegSpawnError,
|
||||
classifyVideoExtractionError,
|
||||
extractAllVideoFrames,
|
||||
} from "./videoFrameExtractor.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function extractionOutputDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-extraction-failure-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("classifyFfmpegSpawnError", () => {
|
||||
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
|
||||
@@ -37,3 +57,74 @@ describe("classifyVideoExtractionError download integrity", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("extractAllVideoFrames download failure metadata", () => {
|
||||
it("threads a query-free fingerprint, allowlisted host, and zero used retries", async () => {
|
||||
const source =
|
||||
"https://media.customer-cdn.example/private/clip.mp4?X-Amz-Signature=super-secret#fragment";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>")),
|
||||
);
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[
|
||||
{
|
||||
id: "private-video-id",
|
||||
src: source,
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
playbackRate: 1,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
},
|
||||
],
|
||||
extractionOutputDir(),
|
||||
{ fps: 30, outputDir: extractionOutputDir() },
|
||||
);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]?.group).toEqual({
|
||||
sourceFingerprint: `sha256:${safeDownloadUrlIdentity(source).urlFingerprint}`,
|
||||
host: "media.customer-cdn.example",
|
||||
statusClass: "other",
|
||||
retry: { phase: "download", used: 0, budget: 1 },
|
||||
});
|
||||
expect(JSON.stringify(result.errors[0]?.group)).not.toContain("super-secret");
|
||||
expect(JSON.stringify(result.errors[0]?.group)).not.toContain("private-video-id");
|
||||
});
|
||||
|
||||
it("collapses an exhausted HTTP retry to status class and normalized host", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await extractAllVideoFrames(
|
||||
[
|
||||
{
|
||||
id: "v1",
|
||||
src: "https://customer-cdn.example/clip.mp4?token=secret",
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
playbackRate: 1,
|
||||
loop: false,
|
||||
hasAudio: false,
|
||||
},
|
||||
],
|
||||
extractionOutputDir(),
|
||||
{ fps: 30, outputDir: extractionOutputDir() },
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
kind: "download_transient",
|
||||
retryable: true,
|
||||
group: {
|
||||
host: "customer-cdn.example",
|
||||
statusClass: "http_5xx",
|
||||
retry: { phase: "download", used: 1, budget: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import {
|
||||
downloadToTemp,
|
||||
isHttpUrl,
|
||||
safeDownloadUrlIdentity,
|
||||
UrlDownloadError,
|
||||
writeUrlDownloadTelemetry,
|
||||
} from "../utils/urlDownloader.js";
|
||||
@@ -280,6 +281,8 @@ export interface VideoExtractionFailure {
|
||||
kind?: VideoExtractionFailureKind;
|
||||
/** Always populated by this engine version; absent legacy values fail closed. */
|
||||
retryable?: boolean;
|
||||
/** Bounded, path-free grouping data safe for producer-owned error metadata. */
|
||||
group?: VideoExtractionFailureGroupDetails;
|
||||
/**
|
||||
* Operator diagnostic retained inside the engine result. Producer-facing
|
||||
* errors must summarize `kind`/counts and must not forward this field: it
|
||||
@@ -288,6 +291,71 @@ export interface VideoExtractionFailure {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type VideoExtractionFailureStatusClass =
|
||||
| "http_4xx"
|
||||
| "http_5xx"
|
||||
| "timeout"
|
||||
| "network"
|
||||
| "other";
|
||||
|
||||
export interface VideoExtractionFailureRetry {
|
||||
phase: "download";
|
||||
used: 0 | 1;
|
||||
budget: 1;
|
||||
}
|
||||
|
||||
export interface VideoExtractionFailureGroupDetails {
|
||||
sourceFingerprint?: string;
|
||||
host?: string;
|
||||
statusClass?: VideoExtractionFailureStatusClass;
|
||||
retry?: VideoExtractionFailureRetry;
|
||||
}
|
||||
|
||||
export interface SafeVideoExtractionSourceIdentity {
|
||||
sourceFingerprint: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/** Query/fragment-free remote identity safe for extraction logs and wire metadata. */
|
||||
export function safeVideoExtractionSourceIdentity(
|
||||
source: string,
|
||||
): SafeVideoExtractionSourceIdentity | null {
|
||||
if (!isHttpUrl(source)) return null;
|
||||
const identity = safeDownloadUrlIdentity(source);
|
||||
return {
|
||||
sourceFingerprint: `sha256:${identity.urlFingerprint}`,
|
||||
host: identity.host ?? "other",
|
||||
};
|
||||
}
|
||||
|
||||
function downloadStatusClass(error: UrlDownloadError): VideoExtractionFailureStatusClass {
|
||||
const status = error.status ?? error.telemetry?.status;
|
||||
if (typeof status === "number" && status >= 400 && status < 500) return "http_4xx";
|
||||
if (typeof status === "number" && status >= 500 && status < 600) return "http_5xx";
|
||||
if (error.kind === "timeout") return "timeout";
|
||||
if (error.kind === "network") return "network";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function downloadFailureGroup(
|
||||
source: string,
|
||||
error: UrlDownloadError,
|
||||
): VideoExtractionFailureGroupDetails {
|
||||
const sourceIdentity = safeVideoExtractionSourceIdentity(source);
|
||||
const failureHost = error.telemetry?.finalHost ?? error.telemetry?.initialHost;
|
||||
const attempt = error.telemetry?.attempt;
|
||||
return {
|
||||
...(sourceIdentity ? { sourceFingerprint: sourceIdentity.sourceFingerprint } : {}),
|
||||
...((failureHost ?? sourceIdentity?.host) ? { host: failureHost ?? sourceIdentity?.host } : {}),
|
||||
statusClass: downloadStatusClass(error),
|
||||
retry: {
|
||||
phase: "download",
|
||||
used: typeof attempt === "number" && attempt >= 2 ? 1 : 0,
|
||||
budget: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class VideoSourceExtractionError extends Error {
|
||||
readonly hyperframesVideoSourceExtractionError = true as const;
|
||||
|
||||
@@ -1546,6 +1614,7 @@ export async function extractAllVideoFrames(
|
||||
videoId: video.id,
|
||||
kind: classified.kind,
|
||||
retryable: classified.retryable,
|
||||
...(err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {}),
|
||||
error: classified.diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,6 +447,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
|
||||
).rejects.toMatchObject({
|
||||
kind: "length_mismatch",
|
||||
retryable: true,
|
||||
telemetry: expect.objectContaining({ attempt: 2 }),
|
||||
} satisfies Partial<UrlDownloadError>);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]);
|
||||
@@ -802,6 +803,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
|
||||
).rejects.toMatchObject({
|
||||
kind: "invalid_payload",
|
||||
retryable: false,
|
||||
telemetry: expect.objectContaining({ attempt: 1 }),
|
||||
} satisfies Partial<UrlDownloadError>);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(temporaryDownloadEntries(dir)).toEqual([]);
|
||||
|
||||
@@ -1113,7 +1113,22 @@ async function downloadWithRetry(
|
||||
return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal);
|
||||
} catch (error) {
|
||||
const classified = classifyDownloadFailure(error);
|
||||
if (!classified.locallyRetryable || attempt >= maxTransientRetries) throw classified;
|
||||
if (!classified.locallyRetryable || attempt >= maxTransientRetries) {
|
||||
const identity = safeDownloadUrlIdentity(url);
|
||||
throw new UrlDownloadError(
|
||||
classified.kind,
|
||||
classified.retryable,
|
||||
classified.message,
|
||||
classified.status,
|
||||
{
|
||||
...classified.telemetry,
|
||||
urlFingerprint: identity.urlFingerprint,
|
||||
initialHost: identity.host,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
classified.locallyRetryable,
|
||||
);
|
||||
}
|
||||
if (classified.retryable) onTransientRetry?.(classified);
|
||||
const identity = safeDownloadUrlIdentity(url);
|
||||
emitDownloadTelemetry(options, {
|
||||
|
||||
Reference in New Issue
Block a user