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
28 changes: 27 additions & 1 deletion platform/src/lib/youtube-processor-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ function failureFrom(payload: unknown): ProcessorFailure['error'] | undefined {
return payload.error;
}

function shouldFallbackResult(operation: YouTubeOperation, result: unknown): boolean {
if (operation.kind !== 'caption-tracks' || !isRecord(result)) return false;
const metadata = result.meta;
return Array.isArray(result.tracks)
&& result.tracks.length === 0
&& isRecord(metadata)
&& metadata.partial === true;
}

function shouldFallbackError(operation: YouTubeOperation, error: YouTubeProcessorError): boolean {
// A transcript NOT_FOUND can mean that one YouTube response omitted its
// caption catalog. Try an independent processor slot before treating it as
// a genuine captionless video.
return operation.kind === 'transcript' && error.code === 'NOT_FOUND';
}

async function resultFrom<T>(response: Response): Promise<T> {
let payload: unknown;
try {
Expand Down Expand Up @@ -194,6 +210,7 @@ export async function runYouTubeOperation<T extends YouTubeOperation>(
for (let index = 0; index < slots.length; index += 1) {
const slot = slots[index]!;
const startedAt = Date.now();
const hasFallback = index < slots.length - 1;
try {
const response = await processorContainer(env, slot).fetch(new Request('http://youtube-processor/operations', {
method: 'POST',
Expand All @@ -202,7 +219,6 @@ export async function runYouTubeOperation<T extends YouTubeOperation>(
signal: AbortSignal.timeout(processorTimeoutMs(env)),
}));

const hasFallback = index < slots.length - 1;
if (hasFallback && RETRYABLE_CONTAINER_STATUSES.has(response.status)) {
logProcessorAttempt(operation.kind, slot, index, response.status, 'fallback', startedAt);
if (response.body) await response.body.cancel().catch(() => undefined);
Expand All @@ -211,10 +227,20 @@ export async function runYouTubeOperation<T extends YouTubeOperation>(
}

const result = await resultFrom<YouTubeOperationResult<T>>(response);
if (hasFallback && shouldFallbackResult(operation, result)) {
logProcessorAttempt(operation.kind, slot, index, response.status, 'fallback', startedAt);
await waitBeforeFallback(env, index);
continue;
}
logProcessorAttempt(operation.kind, slot, index, response.status, 'success', startedAt);
return result;
} catch (error) {
if (error instanceof YouTubeProcessorError) {
if (hasFallback && shouldFallbackError(operation, error)) {
logProcessorAttempt(operation.kind, slot, index, error.status, 'fallback', startedAt);
await waitBeforeFallback(env, index);
continue;
}
logProcessorAttempt(operation.kind, slot, index, error.status, 'processor-error', startedAt);
throw error;
}
Expand Down
111 changes: 111 additions & 0 deletions platform/test/youtube-processor-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,117 @@ describe('YouTube processor client', () => {
expect(requested[0]).not.toBe(requested[1]);
});

test('fails over when caption tracks are empty and partial', async () => {
const operation = { kind: 'caption-tracks', id: 'abcdefghijk' } satisfies YouTubeOperation;
const empty = {
tracks: [], sourceTracks: [], translationLanguages: [], autoTranslationTargets: [],
meta: { source: 'allthingsyoutube', fetchedAt: '2026-08-19T00:00:00.000Z', partial: true, warnings: [] },
};
const complete = {
tracks: [{ id: 'a.en', name: 'English', languageCode: 'en', kind: 'asr', provenance: 'asr' }],
sourceTracks: [{ id: 'a.en', name: 'English', languageCode: 'en', kind: 'asr', provenance: 'asr' }],
translationLanguages: [], autoTranslationTargets: [],
meta: { source: 'allthingsyoutube', fetchedAt: '2026-08-19T00:00:01.000Z', partial: false, warnings: [] },
};
const { env, requested } = environment([
Response.json({ value: empty }),
Response.json({ value: complete }),
]);

await expect(runYouTubeOperation(env, operation)).resolves.toMatchObject({
tracks: [{ id: 'a.en' }],
meta: { partial: false },
});
expect(requested).toHaveLength(2);
expect(requested[0]).not.toBe(requested[1]);
});

test('preserves empty caption tracks after every slot returns a partial result', async () => {
const operation = { kind: 'caption-tracks', id: 'abcdefghijk' } satisfies YouTubeOperation;
const empty = () => Response.json({ value: {
tracks: [], sourceTracks: [], translationLanguages: [], autoTranslationTargets: [],
meta: { source: 'allthingsyoutube', fetchedAt: '2026-08-19T00:00:00.000Z', partial: true, warnings: [] },
} });
const { env, requested } = environment([empty(), empty()]);

await expect(runYouTubeOperation(env, operation)).resolves.toMatchObject({
tracks: [],
meta: { partial: true },
});
expect(requested).toHaveLength(2);
});

test('does not fail over a complete empty caption result', async () => {
const operation = { kind: 'caption-tracks', id: 'abcdefghijk' } satisfies YouTubeOperation;
const { env, requested } = environment([
Response.json({ value: {
tracks: [], sourceTracks: [], translationLanguages: [], autoTranslationTargets: [],
meta: { source: 'allthingsyoutube', fetchedAt: '2026-08-19T00:00:00.000Z', partial: false, warnings: [] },
} }),
Response.json({ value: { tracks: [{ id: 'a.en' }] } }),
]);

await expect(runYouTubeOperation(env, operation)).resolves.toMatchObject({
tracks: [],
meta: { partial: false },
});
expect(requested).toHaveLength(1);
});

test('fails over when a transcript slot reports missing captions', async () => {
const operation = {
kind: 'transcript', id: 'abcdefghijk', granularity: 'word',
} satisfies YouTubeOperation;
const { env, requested } = environment([
Response.json({ error: {
code: 'NOT_FOUND', message: 'No caption track is available.', status: 404, retryable: false,
} }, { status: 404 }),
Response.json({ value: {
videoId: 'abcdefghijk',
track: { id: 'a.en', name: 'English', languageCode: 'en', kind: 'asr', provenance: 'asr' },
segments: [], granularity: 'word', text: 'Recovered transcript',
meta: { source: 'allthingsyoutube', fetchedAt: '2026-08-19T00:00:01.000Z', partial: false, warnings: [] },
} }),
]);

await expect(runYouTubeOperation(env, operation)).resolves.toMatchObject({
videoId: 'abcdefghijk',
text: 'Recovered transcript',
});
expect(requested).toHaveLength(2);
expect(requested[0]).not.toBe(requested[1]);
});

test('preserves missing captions after every transcript slot agrees', async () => {
const operation = {
kind: 'transcript', id: 'abcdefghijk', granularity: 'word',
} satisfies YouTubeOperation;
const missing = () => Response.json({ error: {
code: 'NOT_FOUND', message: 'No caption track is available.', status: 404, retryable: false,
} }, { status: 404 });
const { env, requested } = environment([missing(), missing()]);

await expect(runYouTubeOperation(env, operation)).rejects.toMatchObject({
code: 'NOT_FOUND', status: 404, retryable: false,
});
expect(requested).toHaveLength(2);
});

test('does not fail over unrelated not-found errors', async () => {
const operation = { kind: 'video', id: 'abcdefghijk' } satisfies YouTubeOperation;
const { env, requested } = environment([
Response.json({ error: {
code: 'NOT_FOUND', message: 'Video not found.', status: 404, retryable: false,
} }, { status: 404 }),
Response.json({ value: { id: 'abcdefghijk' } }),
]);

await expect(runYouTubeOperation(env, operation)).rejects.toMatchObject({
code: 'NOT_FOUND', status: 404,
});
expect(requested).toHaveLength(1);
});

test('preserves structured processor errors', async () => {
const operation = { kind: 'video', id: 'abcdefghijk' } satisfies YouTubeOperation;
const failure = Response.json({ error: {
Expand Down
Loading