Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/aws-lambda/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ Resources:
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
MaxAttempts: 0
Expand Down Expand Up @@ -305,6 +307,8 @@ Resources:
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- VIDEO_SOURCE_UNRENDERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED
Expand Down Expand Up @@ -401,6 +405,7 @@ Resources:
- PlanTooLargeError
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- INVALID_VIDEO_METADATA
- PLAN_ARTIFACT_DIGEST_MISMATCH
MaxAttempts: 0
- ErrorEquals: [States.ALL]
Expand Down Expand Up @@ -497,6 +502,7 @@ Resources:
- PLAN_PROTOCOL_UNSUPPORTED
- PlanProtocolUnsupportedError
- PLAN_V2_INTEGRITY_UNRECOVERABLE
- INVALID_VIDEO_METADATA
- PlanV2IntegrityError
- PLAN_ARTIFACT_DIGEST_MISMATCH
- ChromeBinaryUnavailableError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
Expand Down Expand Up @@ -209,6 +211,25 @@ describe("HyperframesRenderStack — snapshot", () => {
}
});

it("routes video failures consistently across SAM/CDK and both plan protocols", () => {
for (const definition of [SYNTHED.definition, readSamDefinition()]) {
const v1 = getV1TaskStates(definition);
const v2 = getV2TaskStates(definition);
for (const planState of [v1.Plan, v2.PlanV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(planState, errors);
expect(errors.has("VIDEO_SOURCE_UNRENDERABLE")).toBe(true);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
expect(errors.has("VIDEO_EXTRACTION_FAILED")).toBe(false);
}
for (const chunkState of [v1.RenderChunk, v2.RenderChunkV2]) {
const errors = new Set<string>();
collectNonRetryableErrors(chunkState, errors);
expect(errors.has("INVALID_VIDEO_METADATA")).toBe(true);
}
}
});

it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
const { definition } = SYNTHED;
const v1 = JSON.stringify({
Expand Down Expand Up @@ -271,6 +292,21 @@ function getV2TaskStates(definition: {
};
}

function getV1TaskStates(definition: {
States: Record<string, unknown>;
}): Record<"Plan" | "RenderChunk" | "Assemble", unknown> {
const renderChunks = requireRecord(definition.States.RenderChunks, "RenderChunks state");
const processor = isRecord(renderChunks.Iterator)
? renderChunks.Iterator
: requireRecord(renderChunks.ItemProcessor, "RenderChunks processor");
const innerStates = requireRecord(processor.States, "RenderChunks processor states");
return {
Plan: definition.States.Plan,
RenderChunk: innerStates.RenderChunk,
Assemble: definition.States.Assemble,
};
}

function readSamDefinition(): { States: Record<string, unknown> } {
const source = readFileSync(
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),
Expand Down
3 changes: 3 additions & 0 deletions packages/aws-lambda/src/cdk/HyperframesRenderStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ export class HyperframesRenderStack extends Construct {
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
Expand All @@ -219,6 +221,7 @@ export class HyperframesRenderStack extends Construct {
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"INVALID_VIDEO_METADATA",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
Expand Down
17 changes: 12 additions & 5 deletions packages/aws-lambda/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
PlanVideosMetadataError,
type AssembleResult,
type ChunkResult,
type PlanResult,
Expand Down Expand Up @@ -243,19 +244,25 @@ describe("handler dispatch", () => {
).toBe(true);
});

it("normalizes producer terminal codes to Step Functions error names", async () => {
it("normalizes producer workflow codes to Step Functions error names", async () => {
for (const code of [
"PLAN_TOO_LARGE",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"INVALID_VIDEO_METADATA",
] as const) {
const tmpRoot = makeTmpRoot();
const s3 = new FakeS3Client();
s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar());
const terminal = Object.assign(new Error(`terminal: ${code}`), {
code,
name: "ProducerError",
});
const terminal =
code === "INVALID_VIDEO_METADATA"
? new PlanVideosMetadataError("test invalid plan video metadata")
: Object.assign(new Error(`terminal: ${code}`), {
code,
name: "ProducerError",
});

await expect(
handler(
Expand Down
7 changes: 5 additions & 2 deletions packages/aws-lambda/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise<L

/**
* AWS Lambda reports `Error.name` to Step Functions, while producer errors
* expose stable machine codes separately. Normalize the terminal codes
* expose stable machine codes separately. Normalize workflow-facing codes
* whose historical class names differ from their orchestration contracts.
*/
// The explicit error-name mapping is the public Step Functions failure contract.
Expand All @@ -149,7 +149,10 @@ function normalizeTerminalErrorName(error: unknown): void {
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
) {
candidate.name = candidate.code;
}
Expand Down
61 changes: 61 additions & 0 deletions packages/gcp-cloud-run/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
import {
CURRENT_PLAN_PROTOCOL,
PLAN_V2_INTEGRITY_UNRECOVERABLE,
PlanVideosMetadataError,
PlanV2IntegrityError,
PlanProtocolUnsupportedError,
type AssembleResult,
Expand Down Expand Up @@ -574,6 +575,66 @@ describe("createApp HTTP mapping", () => {
}
});

it.each([
["VIDEO_SOURCE_UNRENDERABLE", 400],
["VIDEO_EXTRACTION_FAILED", 500],
] as const)("routes producer video code %s with HTTP %s", async (code, expectedStatus) => {
const gcs = new FakeGcs();
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
const app = createApp(
depsWith(gcs, {
renderChunk: async () => {
throw Object.assign(new Error(`test ${code}`), {
name: "ProducerError",
code,
});
},
}),
);
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "renderChunk",
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
PlanHash: PLAN_HASH,
ChunkIndex: 0,
ChunkOutputGcsPrefix: "gs://b/renders/r1/",
Format: "mp4",
}),
});

expect(res.status).toBe(expectedStatus);
const body = (await res.json()) as { error: string };
expect(body.error).toBe(code);
});

it("routes the real plan metadata error as non-retryable", async () => {
const gcs = new FakeGcs();
await seedProjectTar(gcs, "gs://b/sites/invalid-video-metadata/project.tar.gz");
const app = createApp(
depsWith(gcs, {
plan: async () => {
throw new PlanVideosMetadataError("test invalid plan video metadata");
},
}),
);
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
Action: "plan",
ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz",
PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/",
Config: { fps: 30, width: 640, height: 360, format: "mp4" },
}),
});

expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("INVALID_VIDEO_METADATA");
});

it("returns 500 for a retryable/unknown error", async () => {
const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable)
const app = createApp(depsWith(gcs));
Expand Down
7 changes: 6 additions & 1 deletion packages/gcp-cloud-run/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ function normalizeTerminalErrorName(error: unknown): void {
if (
candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" ||
candidate.code === "PLAN_TOO_LARGE" ||
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE"
candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" ||
candidate.code === "VIDEO_SOURCE_UNRENDERABLE" ||
candidate.code === "VIDEO_EXTRACTION_FAILED" ||
candidate.code === "INVALID_VIDEO_METADATA"
) {
candidate.name = candidate.code;
}
Expand Down Expand Up @@ -899,6 +902,8 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"PLAN_PROTOCOL_UNSUPPORTED",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"VIDEO_SOURCE_UNRENDERABLE",
"INVALID_VIDEO_METADATA",
// Producer error class names (`.name`) + their string code aliases — the
// class sets `.name` to the class name but wraps a `code`; cover both so a
// raw-code throw is caught too. Mirrors the AWS state machine's
Expand Down
3 changes: 2 additions & 1 deletion packages/producer/src/distributed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
type EffectiveChunkResult,
// Error codes + classes
FFMPEG_VERSION_MISMATCH,
INVALID_VIDEO_METADATA,
PLAN_HASH_MISMATCH,
RenderChunkValidationError,
} from "./services/distributed/renderChunk.js";
Expand Down Expand Up @@ -142,7 +143,7 @@ export {
// ── Format union ────────────────────────────────────────────────────────────
// Canonical output-format type. The aws-lambda package re-exports it so
// CLI / adopter SDKs can derive runtime allowlists from one source.
export type { DistributedFormat } from "./services/distributed/shared.js";
export { PlanVideosMetadataError, type DistributedFormat } from "./services/distributed/shared.js";

// ── Plan-time shared types from `freezePlan` ───────────────────────────────
// Re-exported so adopters that deserialize a planDir's `meta/encoder.json`
Expand Down
3 changes: 3 additions & 0 deletions packages/producer/src/server.errorCode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ describe("extractSafeRenderErrorCode", () => {
expect(extractSafeRenderErrorCode({ code: "VIDEO_SOURCE_UNRENDERABLE" })).toBe(
"VIDEO_SOURCE_UNRENDERABLE",
);
expect(extractSafeRenderErrorCode({ code: "INVALID_VIDEO_METADATA" })).toBe(
"INVALID_VIDEO_METADATA",
);
});

it("does not forward arbitrary codes or parse message text", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/producer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ interface PreparedRenderInput {

const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"INVALID_VIDEO_METADATA",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
]);
Expand Down
77 changes: 77 additions & 0 deletions packages/producer/src/services/distributed/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,83 @@ describe("plan() — golden planDir + planHash determinism", () => {
// runtime resolution variance on the CI host.
const TIMEOUT_MS = 30_000;

it(
"fails closed when an open-ended distributed video source cannot be extracted",
async () => {
const brokenProjectDir = join(runRoot, "broken-video-project");
const brokenPlanDir = join(runRoot, "broken-video-plan");
mkdirSync(brokenProjectDir, { recursive: true });
mkdirSync(brokenPlanDir, { recursive: true });
writeFileSync(
join(brokenProjectDir, "index.html"),
`<!doctype html>
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
<video id="hero" src="missing.mp4" data-start="0"></video>
</div>`,
);

let caught: unknown;
try {
await plan(
brokenProjectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
brokenPlanDir,
);
} catch (err) {
caught = err;
}

expect(caught).toHaveProperty("name", "VideoExtractionStageError");
expect(caught).toHaveProperty("code", "VIDEO_SOURCE_UNRENDERABLE");
expect(caught).toHaveProperty("retryable", false);
expect(existsSync(join(brokenPlanDir, "meta", "videos.json"))).toBe(false);
},
TIMEOUT_MS,
);

it(
"maps an open-ended remote video HTTP 404 to a terminal source error",
async () => {
const brokenProjectDir = join(runRoot, "remote-404-video-project");
const brokenPlanDir = join(runRoot, "remote-404-video-plan");
mkdirSync(brokenProjectDir, { recursive: true });
mkdirSync(brokenPlanDir, { recursive: true });
writeFileSync(
join(brokenProjectDir, "index.html"),
`<!doctype html>
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
<video id="hero" src="https://cdn.example/missing.mp4" data-start="0"></video>
</div>`,
);
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls += 1;
return new Response(null, { status: 404, statusText: "Not Found" });
}) as typeof fetch;

let caught: unknown;
try {
await plan(
brokenProjectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
brokenPlanDir,
);
} catch (err) {
caught = err;
} finally {
globalThis.fetch = originalFetch;
}

expect(fetchCalls).toBeGreaterThan(0);
expect(caught).toHaveProperty("name", "VideoExtractionStageError");
expect(caught).toHaveProperty("code", "VIDEO_SOURCE_UNRENDERABLE");
expect(caught).toHaveProperty("retryable", false);
expect(existsSync(join(brokenPlanDir, "meta", "videos.json"))).toBe(false);
},
TIMEOUT_MS,
);

it(
"produces the documented planDir layout",
async () => {
Expand Down
Loading
Loading