diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 9a04b716..a55d3e0d 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -7959,6 +7959,41 @@ "ffmpeg_success" ] }, + "transportCode": { + "type": "string", + "enum": [ + "TIMEOUT_CONNECT", + "TIMEOUT_HANDSHAKE", + "TIMEOUT_HEADERS", + "TIMEOUT_IDLE", + "TIMEOUT_TOTAL" + ] + }, + "timeoutPhase": { + "type": "string", + "enum": [ + "connect", + "handshake", + "headers", + "idle", + "total", + "request", + "attempt", + "extraction" + ] + }, + "requestPhase": { + "type": "string", + "enum": [ + "headers", + "body" + ] + }, + "requestElapsedMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, "profile": { "type": "string", "enum": [ @@ -9335,6 +9370,41 @@ "ffmpeg_success" ] }, + "transportCode": { + "type": "string", + "enum": [ + "TIMEOUT_CONNECT", + "TIMEOUT_HANDSHAKE", + "TIMEOUT_HEADERS", + "TIMEOUT_IDLE", + "TIMEOUT_TOTAL" + ] + }, + "timeoutPhase": { + "type": "string", + "enum": [ + "connect", + "handshake", + "headers", + "idle", + "total", + "request", + "attempt", + "extraction" + ] + }, + "requestPhase": { + "type": "string", + "enum": [ + "headers", + "body" + ] + }, + "requestElapsedMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, "profile": { "type": "string", "enum": [ @@ -10164,6 +10234,41 @@ "ffmpeg_success" ] }, + "transportCode": { + "type": "string", + "enum": [ + "TIMEOUT_CONNECT", + "TIMEOUT_HANDSHAKE", + "TIMEOUT_HEADERS", + "TIMEOUT_IDLE", + "TIMEOUT_TOTAL" + ] + }, + "timeoutPhase": { + "type": "string", + "enum": [ + "connect", + "handshake", + "headers", + "idle", + "total", + "request", + "attempt", + "extraction" + ] + }, + "requestPhase": { + "type": "string", + "enum": [ + "headers", + "body" + ] + }, + "requestElapsedMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, "profile": { "type": "string", "enum": [ diff --git a/platform/src/lib/extraction-diagnostics.ts b/platform/src/lib/extraction-diagnostics.ts index 89b9ec3b..afe120d8 100644 --- a/platform/src/lib/extraction-diagnostics.ts +++ b/platform/src/lib/extraction-diagnostics.ts @@ -6,6 +6,10 @@ const metric = z.number().nonnegative().max(Number.MAX_SAFE_INTEGER); export const extractionEventSchema = z.object({ stage: z.enum(['player', 'player_response', 'download', 'complete', 'request', 'image_normalized', 'caption_metadata', 'caption_retry', 'media_candidates', 'media_http', 'media_transfer', 'media_retry', 'media_retry_skipped', 'ffmpeg', 'ffmpeg_success']), + transportCode: z.enum(['TIMEOUT_CONNECT', 'TIMEOUT_HANDSHAKE', 'TIMEOUT_HEADERS', 'TIMEOUT_IDLE', 'TIMEOUT_TOTAL']).optional(), + timeoutPhase: z.enum(['connect', 'handshake', 'headers', 'idle', 'total', 'request', 'attempt', 'extraction']).optional(), + requestPhase: z.enum(['headers', 'body']).optional(), + requestElapsedMs: metric.optional(), profile: z.enum(['IOS', 'ANDROID_VR', 'MWEB', 'WEB', 'ios', 'android', 'android_vr', 'mweb', 'web']).optional(), outcome: z.enum(['selected', 'skipped', 'error', 'success']).optional(), playabilityStatus: z.enum(['OK', 'LOGIN_REQUIRED', 'UNPLAYABLE', 'ERROR', 'LIVE_STREAM_OFFLINE', 'CONTENT_CHECK_REQUIRED', 'AGE_CHECK_REQUIRED', 'UNKNOWN']).optional(), diff --git a/platform/src/lib/youtube-cache-coordinator.ts b/platform/src/lib/youtube-cache-coordinator.ts index 276917dd..f1deb095 100644 --- a/platform/src/lib/youtube-cache-coordinator.ts +++ b/platform/src/lib/youtube-cache-coordinator.ts @@ -116,7 +116,7 @@ export class YouTubeCacheCoordinatorCore { const diagnostics: ExtractionAttempt[] = []; const onDiagnostic: ExtractionDiagnosticSink = event => { - if (['transcript','storyboard','frames'].includes(request.operation.kind) && diagnostics.length < 4) { + if (['transcript','storyboard','frames'].includes(request.operation.kind) && diagnostics.length < 5) { emitExtractionDiagnostic(item => { diagnostics.push(item); }, event); } }; diff --git a/platform/src/lib/youtube-worker-extraction.ts b/platform/src/lib/youtube-worker-extraction.ts index 3be90f64..52e02cac 100644 --- a/platform/src/lib/youtube-worker-extraction.ts +++ b/platform/src/lib/youtube-worker-extraction.ts @@ -115,7 +115,7 @@ export function createWorkerExtractionRunner(deps: WorkerExtractionDependencies) for (const [index, route] of routes.entries()) { deadline.throwIfAborted(); const attempt = new AbortController(); - const timeout = route.egress === 'direct' ? bounded(env.YOUTUBE_DIRECT_TIMEOUT_MS, 8_000, 100, 25_000) : bounded(env.YOUTUBE_PROXY_TIMEOUT_MS, 25_000, 100, 60_000); + const timeout = route.egress === 'direct' ? bounded(env.YOUTUBE_DIRECT_TIMEOUT_MS, 5_000, 100, 25_000) : bounded(env.YOUTUBE_PROXY_TIMEOUT_MS, 20_000, 100, 60_000); const timer = setTimeout(() => attempt.abort(new DOMException('Attempt deadline', 'TimeoutError')), timeout); const attemptSignal = AbortSignal.any([deadline, attempt.signal]); const started = Date.now(); @@ -136,21 +136,39 @@ export function createWorkerExtractionRunner(deps: WorkerExtractionDependencies) const requestSignal = init.signal ?? (input instanceof Request ? input.signal : undefined); const activeSignal = requestSignal ? AbortSignal.any([attemptSignal, requestSignal]) : attemptSignal; activeSignal.throwIfAborted(); - const response = await abortable(activeSignal, () => fetchImpl(input, { ...init, signal: activeSignal })); - if (response.status === 429 || response.status >= 500) { - const raw = response.headers.get('retry-after'); - const seconds = raw === null ? NaN : Number(raw); - const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw ?? '') - Date.now(); - if (Number.isFinite(delay)) retryAfter = Math.max(retryAfter, delay, 0); - } - const bytes = await boundedBody(response, activeSignal); - bytesRead += bytes.length; - if (bytesRead > MAX_ATTEMPT_BYTES) throw new YouTubeProcessorError('INVALID_RESPONSE', 'YouTube extraction exceeded the byte budget.', 502, true); + const requestStarted = Date.now(); const path = new URL(input instanceof Request ? input.url : String(input)).pathname; - record({ stage: path === '/watch' || path.endsWith('/player') ? 'caption_metadata' : 'download', outcome: response.ok ? 'success' : 'error', status: response.status, elapsedMs: Date.now() - started }); - const headers = new Headers(response.headers); - headers.delete('content-length'); headers.delete('content-encoding'); - return new Response([204, 205, 304].includes(response.status) ? null : bytes, { status: response.status, statusText: response.statusText, headers }); + const stage = path === '/watch' || path.endsWith('/player') ? 'caption_metadata' : 'download'; + let requestPhase: 'headers' | 'body' = 'headers'; + try { + const response = await abortable(activeSignal, () => fetchImpl(input, { ...init, signal: activeSignal })); + if (response.status === 429 || response.status >= 500) { + const raw = response.headers.get('retry-after'); + const seconds = raw === null ? NaN : Number(raw); + const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw ?? '') - Date.now(); + if (Number.isFinite(delay)) retryAfter = Math.max(retryAfter, delay, 0); + } + requestPhase = 'body'; + const bytes = await boundedBody(response, activeSignal); + bytesRead += bytes.length; + if (bytesRead > MAX_ATTEMPT_BYTES) throw new YouTubeProcessorError('INVALID_RESPONSE', 'YouTube extraction exceeded the byte budget.', 502, true); + record({ stage, outcome: response.ok ? 'success' : 'error', status: response.status, elapsedMs: Date.now() - started }); + const headers = new Headers(response.headers); + headers.delete('content-length'); headers.delete('content-encoding'); + return new Response([204, 205, 304].includes(response.status) ? null : bytes, { status: response.status, statusText: response.statusText, headers }); + } catch (error) { + // Capture before the library wraps transport errors as UPSTREAM_ERROR. + // Never copy error messages, detail objects, URLs, or nested causes. + const phases = { TIMEOUT_CONNECT: 'connect', TIMEOUT_HANDSHAKE: 'handshake', TIMEOUT_HEADERS: 'headers', TIMEOUT_IDLE: 'idle', TIMEOUT_TOTAL: 'total' } as const; + const transportCode = Object.keys(phases).find(code => code === (error as { code?: unknown } | null)?.code) as keyof typeof phases | undefined; + const timeoutPhase = deadline.aborted && deadline.reason?.name === 'TimeoutError' ? 'extraction' + : attempt.signal.aborted && attempt.signal.reason?.name === 'TimeoutError' ? 'attempt' + : requestSignal?.aborted && requestSignal.reason?.name === 'TimeoutError' ? 'request' + : transportCode ? phases[transportCode] : undefined; + record({ stage, outcome: 'error', transportCode, timeoutPhase, requestPhase, + requestElapsedMs: Date.now() - requestStarted, elapsedMs: Date.now() - started }); + throw error; + } }; const value = await abortable(attemptSignal, () => deps.execute(operation, trackedFetch)); attemptSignal.throwIfAborted(); @@ -171,7 +189,10 @@ export function createWorkerExtractionRunner(deps: WorkerExtractionDependencies) failureKind = extractionFailureKind(error, attemptSignal); retry = !deadline.aborted && index + 1 < routes.length && shouldFallbackError(operation, failure); outcome = retry ? 'fallback' : 'failed'; - record({ stage: 'request', outcome: 'error', code: SAFE_CODES.find(code => code === failure.code) ?? 'UNKNOWN', elapsedMs: Date.now() - started }); + record({ stage: 'request', outcome: 'error', code: SAFE_CODES.find(code => code === failure.code) ?? 'UNKNOWN', + timeoutPhase: deadline.aborted && deadline.reason?.name === 'TimeoutError' ? 'extraction' + : attempt.signal.aborted && attempt.signal.reason?.name === 'TimeoutError' ? 'attempt' : undefined, + elapsedMs: Date.now() - started }); if (!retry) throw operation.kind === 'transcript' && failure.code === 'NOT_FOUND' && upstreamFailure ? upstreamFailure : failure; } finally { clearTimeout(timer); diff --git a/platform/src/lib/youtube-worker-runtime.ts b/platform/src/lib/youtube-worker-runtime.ts index 69a55416..81de2bb1 100644 --- a/platform/src/lib/youtube-worker-runtime.ts +++ b/platform/src/lib/youtube-worker-runtime.ts @@ -11,7 +11,9 @@ export type WorkerYouTubeResult = YouTubeOperationResult export async function executeWorkerYouTubeOperation(operation: WorkerYouTubeOperation, fetchImpl: typeof fetch): Promise { // Avoid multiplying library retries by operation retries. Fresh metadata is // retrieved on every operation retry, including malformed-caption recovery. - const options = { fetch: fetchImpl, retry: { policy: { maxAttempts: 1 } } }; + // Match the proxy transport ceiling instead of inheriting the library's + // 10-second request deadline. The runner still enforces the shorter direct budget. + const options = { fetch: fetchImpl, retry: { policy: { maxAttempts: 1, attemptTimeoutMs: 20_000 } } }; const client = createYouTubeClient(options); switch (operation.kind) { case 'search': return client.search(operation.query, operation.filters ?? {}); diff --git a/platform/src/lib/youtube-worker-transport.ts b/platform/src/lib/youtube-worker-transport.ts index 8d935ead..c5a7cc5f 100644 --- a/platform/src/lib/youtube-worker-transport.ts +++ b/platform/src/lib/youtube-worker-transport.ts @@ -15,7 +15,7 @@ export function createWorkerProxyTransport(proxy: string): YouTubeFetchTransport trust: { mode: 'system' }, maxBodyBytes: 8 * 1024 * 1024, maxRedirects: 3, - timeouts: { connectMs: 10_000, handshakeMs: 15_000, headersMs: 15_000, idleMs: 10_000, totalMs: 25_000 }, + timeouts: { connectMs: 5_000, handshakeMs: 8_000, headersMs: 12_000, idleMs: 8_000, totalMs: 20_000 }, }); return { fetch: (input, init) => client.fetch(input, init), diff --git a/platform/src/lib/youtube.ts b/platform/src/lib/youtube.ts index 503cba80..b0757b7f 100644 --- a/platform/src/lib/youtube.ts +++ b/platform/src/lib/youtube.ts @@ -144,7 +144,7 @@ async function cached( ); } if (Array.isArray(response.diagnostics)) { - for (const event of response.diagnostics.slice(0, 4)) emitExtractionDiagnostic(onDiagnostic, event); + for (const event of response.diagnostics.slice(0, 5)) emitExtractionDiagnostic(onDiagnostic, event); } if (!response.ok && response.error) { if (response.error.apiStatus) throw new ApiError(response.error.apiStatus,response.error.code,response.error.message); diff --git a/platform/test/youtube-cache-coordinator.test.ts b/platform/test/youtube-cache-coordinator.test.ts index fe18990d..2c14f1e9 100644 --- a/platform/test/youtube-cache-coordinator.test.ts +++ b/platform/test/youtube-cache-coordinator.test.ts @@ -1,3 +1,4 @@ +import { extractionFixture } from './fixtures/extraction-diagnostic'; import { readYouTubeCacheEntry, YouTubeCacheCoordinatorCore } from '../src/lib/youtube-cache-coordinator'; import type { YouTubeOperation } from '../src/lib/youtube-processor-client'; @@ -110,3 +111,16 @@ describe('YouTube cache coordinator', () => { expect(loader).not.toHaveBeenCalled(); }); }); + + +test('forwards all five Worker attempts including precise timeout events', async () => { + const cache = { get: vi.fn(async () => null), put: vi.fn(async () => {}) }; + const coordinator = new YouTubeCacheCoordinatorCore(environment(cache), async (_env, _op, onDiagnostic) => { + for (let attempt = 1; attempt <= 5; attempt++) onDiagnostic?.({ ...extractionFixture, attempt, + events: [{ stage: 'download', outcome: 'error', transportCode: 'TIMEOUT_IDLE', timeoutPhase: 'idle', requestPhase: 'body', requestElapsedMs: 8000 }] }); + return { text: 'Recovered', segments: [] }; + }); + const response = await coordinator.getOrLoad({ ...request, operation: { kind: 'transcript', id: 'abcdefghijk', granularity: 'word' }, resourceType: 'transcript' }); + expect(response.diagnostics).toHaveLength(5); + expect(response.diagnostics?.[4]).toMatchObject({ attempt: 5, events: [expect.objectContaining({ transportCode: 'TIMEOUT_IDLE', timeoutPhase: 'idle' })] }); +}); diff --git a/platform/test/youtube-worker-extraction.test.ts b/platform/test/youtube-worker-extraction.test.ts index b10dabbf..eeadb50a 100644 --- a/platform/test/youtube-worker-extraction.test.ts +++ b/platform/test/youtube-worker-extraction.test.ts @@ -198,3 +198,85 @@ test('a stalled transport close cannot hold the operation indefinitely', async ( await vi.advanceTimersByTimeAsync(1001); await expect(pending).resolves.toBe(transcript); }); + +test.each([ + ['TIMEOUT_CONNECT', 'connect'], ['TIMEOUT_HANDSHAKE', 'handshake'], + ['TIMEOUT_HEADERS', 'headers'], ['TIMEOUT_IDLE', 'idle'], ['TIMEOUT_TOTAL', 'total'], +] as const)('preserves %s before the real library wraps the caption failure', async (code, timeoutPhase) => { + const diagnostics: ExtractionAttempt[] = []; + let failed = false; + const proxyFetch: typeof fetch = async input => { + const url = String(input); + if (url.includes('/watch?')) return new Response('', { status: 404 }); + if (url.includes('/player')) return Response.json({ playabilityStatus: { status: 'OK' }, captions: { + playerCaptionsTracklistRenderer: { captionTracks: [{ baseUrl: 'https://captions.test/en?secret=signed', languageCode: 'en', vssId: '.en' }] }, + } }); + if (!failed) { + failed = true; + const error = Object.assign(new Error('secret proxy credentials and signed URL'), { code, detail: { secret: 'private' } }); + if (code === 'TIMEOUT_IDLE') return new Response(new ReadableStream({ start(controller) { controller.error(error); } })); + throw error; + } + return Response.json({ events: [{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Recovered' }] }] }); + }; + const run = createWorkerExtractionRunner({ execute: executeWorkerYouTubeOperation, + directFetch: async () => new Response('', { status: 429 }), + proxyTransport: () => ({ fetch: proxyFetch, close: async () => {} }), + }); + await expect(run(env(), { ...operation, lang: undefined }, e => diagnostics.push(e))).resolves.toMatchObject({ text: 'Recovered' }); + expect(diagnostics[1]!.events).toContainEqual(expect.objectContaining({ stage: 'download', outcome: 'error', transportCode: code, timeoutPhase, requestPhase: code === 'TIMEOUT_IDLE' ? 'body' : 'headers', requestElapsedMs: expect.any(Number) })); + expect(diagnostics.at(-1)).toMatchObject({ outcome: 'success' }); + expect(JSON.stringify(diagnostics)).not.toMatch(/secret|private|signed/); +}); + +test('the real library permits a proxy caption response taking twelve seconds', async () => { + vi.useFakeTimers(); + vi.spyOn(AbortSignal, 'timeout').mockImplementation(ms => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('Timed out', 'TimeoutError')), ms); + return controller.signal; + }); + const { run, directFetch, proxyFetch } = harness(executeWorkerYouTubeOperation); + directFetch.mockResolvedValue(new Response('', { status: 429 })); + proxyFetch.mockImplementation(async input => { + const url = String(input); + if (url.includes('/watch?')) return new Response('', { status: 404 }); + if (url.includes('/player')) return Response.json({ playabilityStatus: { status: 'OK' }, captions: { + playerCaptionsTracklistRenderer: { captionTracks: [{ baseUrl: 'https://captions.test/en', languageCode: 'en', vssId: '.en' }] }, + } }); + await new Promise(resolve => setTimeout(resolve, 12_000)); + return Response.json({ events: [{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Recovered' }] }] }); + }); + const diagnostics: ExtractionAttempt[] = []; + const pending = run(env(), { ...operation, lang: undefined }, event => diagnostics.push(event)); + await vi.advanceTimersByTimeAsync(12_010); + await expect(pending).resolves.toMatchObject({ text: 'Recovered' }); + expect(diagnostics).toHaveLength(2); + expect(diagnostics.at(-1)).toMatchObject({ egress: 'proxy', outcome: 'success', elapsedMs: 12_000 }); +}); + +test('default direct and proxy attempt deadlines bound stalled requests and identify the timeout', async () => { + vi.useFakeTimers(); + const { run, directFetch, proxyFetch } = harness(async (_op, fetchImpl) => { await fetchImpl('https://captions.test'); return transcript; }); + directFetch.mockImplementation(() => new Promise(() => {})); + proxyFetch.mockImplementationOnce(() => new Promise(() => {})); + const diagnostics: ExtractionAttempt[] = []; + const pending = run(env(), operation, event => diagnostics.push(event)); + await vi.advanceTimersByTimeAsync(24_999); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ elapsedMs: 5000, failureKind: 'timeout' }); + await vi.advanceTimersByTimeAsync(20); + await expect(pending).resolves.toBe(transcript); + expect(diagnostics[1]).toMatchObject({ elapsedMs: 20000, failureKind: 'timeout' }); + for (const diagnostic of diagnostics.slice(0, 2)) expect(diagnostic.events).toContainEqual(expect.objectContaining({ timeoutPhase: 'attempt' })); +}); + +test('unknown transport codes remain private', async () => { + const diagnostics: ExtractionAttempt[] = []; + const { run, directFetch } = harness(async (_op, fetchImpl) => { await fetchImpl('https://captions.test'); return transcript; }); + directFetch.mockRejectedValue({ code: 'secret credentials', message: 'private' }); + await expect(run(env({ OUTBOUND_PROXY_URLS: '' }), operation, e => diagnostics.push(e))).rejects.toThrow(); + expect(JSON.stringify(diagnostics)).not.toMatch(/secret|private/); + expect(diagnostics[0]!.events[0]).toMatchObject({ stage: 'download', outcome: 'error', requestPhase: 'headers' }); + expect(diagnostics[0]!.events[0]!.transportCode).toBeUndefined(); +}); diff --git a/platform/worker-configuration.d.ts b/platform/worker-configuration.d.ts index fbbcd589..cb84eb7b 100644 --- a/platform/worker-configuration.d.ts +++ b/platform/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=platform/wrangler.jsonc platform/worker-configuration.d.ts` (hash: 14780a3f802759f16f37aa3dd856cd79) +// Generated by Wrangler by running `wrangler types` (hash: beae838a1913ca166a1cec60ed9aa1a4) // Runtime types generated with workerd@1.20260801.1 2026-08-08 nodejs_compat interface __BaseEnv_Env { YOUTUBE_CACHE: KVNamespace; @@ -35,8 +35,8 @@ interface __BaseEnv_Env { LANDING_DEMO_RATE_LIMIT_MODE: "enforced"; YOUTUBE_EXTRACTION_BACKEND: "worker"; YOUTUBE_EXTRACTION_TIMEOUT_MS: "120000"; - YOUTUBE_DIRECT_TIMEOUT_MS: "8000"; - YOUTUBE_PROXY_TIMEOUT_MS: "25000"; + YOUTUBE_DIRECT_TIMEOUT_MS: "5000"; + YOUTUBE_PROXY_TIMEOUT_MS: "20000"; YOUTUBE_PROXY_MAX_ATTEMPTS: "4"; YOUTUBE_EXTRACTION_RETRY_BASE_MS: "250"; YOUTUBE_PROCESSOR_INSTANCE_COUNT: "2"; diff --git a/platform/wrangler.jsonc b/platform/wrangler.jsonc index 61834e26..1649c6fc 100644 --- a/platform/wrangler.jsonc +++ b/platform/wrangler.jsonc @@ -30,8 +30,8 @@ // Core extraction runs in Workers. Set to container to roll back. "YOUTUBE_EXTRACTION_BACKEND": "worker", "YOUTUBE_EXTRACTION_TIMEOUT_MS": "120000", - "YOUTUBE_DIRECT_TIMEOUT_MS": "8000", - "YOUTUBE_PROXY_TIMEOUT_MS": "25000", + "YOUTUBE_DIRECT_TIMEOUT_MS": "5000", + "YOUTUBE_PROXY_TIMEOUT_MS": "20000", "YOUTUBE_PROXY_MAX_ATTEMPTS": "4", "YOUTUBE_EXTRACTION_RETRY_BASE_MS": "250", "YOUTUBE_PROCESSOR_INSTANCE_COUNT": "2", diff --git a/reference/engineering/WORKER_EXTRACTION.md b/reference/engineering/WORKER_EXTRACTION.md index fca92917..e4d4c400 100644 --- a/reference/engineering/WORKER_EXTRACTION.md +++ b/reference/engineering/WORKER_EXTRACTION.md @@ -45,14 +45,18 @@ Existing `video2ctx` secrets are reused. `OUTBOUND_PROXY_URLS` is a JSON array o | --- | --- | --- | | `YOUTUBE_EXTRACTION_BACKEND` | `worker` | Set to `container` to roll back core extraction | | `YOUTUBE_EXTRACTION_TIMEOUT_MS` | `120000` | Entire operation including retry waits | -| `YOUTUBE_DIRECT_TIMEOUT_MS` | `8000` | Direct attempt budget | -| `YOUTUBE_PROXY_TIMEOUT_MS` | `25000` | Budget for each proxy attempt | +| `YOUTUBE_DIRECT_TIMEOUT_MS` | `5000` | Direct attempt budget | +| `YOUTUBE_PROXY_TIMEOUT_MS` | `20000` | Budget for each proxy attempt | | `YOUTUBE_PROXY_MAX_ATTEMPTS` | `4` | Proxy attempts after direct access | | `YOUTUBE_EXTRACTION_RETRY_BASE_MS` | `250` | Initial jittered exponential delay | The pool starts at a random slot and visits every configured slot before repeating. A single gateway can receive several attempts; a changed exit IP depends on the Decodo session configuration. The runner honors `Retry-After`, bounded by the total deadline. Invalid input and confirmed authorization restrictions are terminal. Transcript failures otherwise retain the existing fallback policy because missing-caption labels can result from blocked upstream requests. Partial empty caption catalogs probe distinct routes once. Bot-challenged video metadata triggers fallback; ordinary private-video metadata does not. -Limits are 8 MiB per response and 32 MiB across an attempt. Timeouts cover response reads as well as connection setup. The proxy library has additional per-request timeouts, including a 25-second total; increasing the operation setting does not raise that transport ceiling. Cleanup is attempted even after cancellation, with at most one second spent waiting for it. +Limits are 8 MiB per response and 32 MiB across an attempt. Timeouts cover response reads as well as connection setup. The proxy library has additional per-request timeouts, including a 20-second total; increasing the operation setting does not raise that transport ceiling. Cleanup is attempted even after cancellation, with at most one second spent waiting for it. + +Proxy request phase limits are 5 seconds for connection/proxy setup, 8 seconds for TLS handshake, 12 seconds for response headers, and 8 seconds without incoming body data. The idle limit measures silence, not the total download duration. The platform explicitly sets the shared library's per-request deadline to 20 seconds as well; leaving its default would abort caption requests after 10 seconds despite the larger runner budget. A progressing request can still succeed after 10 seconds within the 20-second attempt budget. + +Transcript request errors record the allowlisted transport timeout code and phase before the library wraps them. They also record whether the fetch was waiting for headers or reading the body, and the request duration. Library request deadlines are labeled `request`; runner deadlines are labeled `attempt` or `extraction`. These optional fields preserve compatibility with historical diagnostics. All five attempts survive coordinator forwarding. Safe attempt logs contain route, slot, outcome, duration, byte count and status, without proxy credentials or signed YouTube URLs. Transcript diagnostics add optional `backend` and `egress` fields and allow five attempts, preserving older records. An earlier upstream transcript failure is retained when a later route reports `NOT_FOUND`. diff --git a/reference/engineering/WORKER_EXTRACTION_RESULTS.md b/reference/engineering/WORKER_EXTRACTION_RESULTS.md index 51f58652..162b3613 100644 --- a/reference/engineering/WORKER_EXTRACTION_RESULTS.md +++ b/reference/engineering/WORKER_EXTRACTION_RESULTS.md @@ -33,3 +33,17 @@ The temporary Worker had a 30,000 ms CPU limit. Deployment reported 32 ms startu Skipped tests were reported by the existing suites. PR CI also passed auth and dashboard browser E2E. Sustained load testing and an independent review of the new TLS dependency remain follow-up work. The PR now configures Worker extraction as the default for the next deployment; the deployed production Worker has not been changed by this test. The temporary Worker was deleted after verification, and the local copied deployment secrets were removed. The checked-in fixture contains fixed test cases and no credentials. Follow `WORKER_EXTRACTION.md` for configuration, source dependency setup and rollback. + +## Timeout tuning verification + +A later isolated deployment on 2026-09-27 tested the 5-second direct attempt and 20-second proxy attempt budgets. Proxy phase limits were connection 5 seconds, TLS 8 seconds, headers 12 seconds, and body idle 8 seconds. The shared library request deadline was explicitly aligned to 20 seconds. + +All eight final live transcript checks passed: three videos plus word granularity, each with normal direct-first routing and forced proxy fallback. Normal runs took 787 to 6,975 ms; forced-proxy runs took 1,461 to 2,453 ms. The 6,975 ms run recorded a direct attempt timeout at exactly 5,000 ms and then recovered through the proxy. These checks verify nonempty transcript text and segments, not just metadata responses. + +Earlier live checks uncovered the library's previously inherited 10-second request deadline: two caption requests failed after exactly 10,000 ms, producing 11.9-second attempts after metadata preparation. This is why the platform now sets the library deadline explicitly. A deterministic regression through the real library proves a caption request can succeed after 12 seconds. Other tests cover all five transport timeout codes, response-body failures, redaction, runner deadlines, and forwarding all five attempt diagnostics. + +French translation returned HTTP 429 across direct and proxy attempts in both earlier translation checks. Those failures arrived before the timeout limits and were not timeout cancellations. Translation remains unverified against the live upstream in this tuning pass; mocked library translation coverage passes. The local single proxy setting was used, not the full production proxy pool. + +Platform type checking, generated API documentation checks, and 818 unit tests passed, with seven existing skips. The temporary Worker was `transcript-timeout-check-927`; production was not deployed by these checks. Monitor failure rate and latency after deployment because a bounded live sample does not establish long-term reliability. + +Wrangler confirmed deletion of the temporary timeout Worker, and its copied local secrets file was removed.