A fallback strategy for yt-dlp extraction failures on SABR-only YouTube streams.
YouTube's SABR-only experiment (yt-dlp #12482) strips URLs from android-client formats for affected sessions. A client that worked yesterday returns HTTP Error 403 today, and retrying the same client doesn't help — the format URL is already dead before the retry starts.
The obvious fix is to list several clients:
'extractor_args': {'youtube': {'player_client': ['android_vr', 'android', 'web']}}This does not work, and understanding why is the whole point of this library.
yt-dlp queries every client in that list, merges all their formats into one pool, and then picks the best format by quality sorting — not by client order. So a merged list will happily select format 251 from web and die on a 403, even when tv_simply alone would have downloaded the same video without issue. Listing more clients gives the sorter more chances to pick a dead URL, not fewer.
The fix is to try one narrow client set per attempt, so the working client's formats are the only ones in the pool.
Walk an ordered ladder of narrow client sets. Advance a rung only on errors a different client can plausibly fix; propagate everything else immediately.
rung 1: ['tv_simply'] → 403 on media fetch, advance
rung 2: ['web_embedded'] → success
Three predicates advance a rung:
| Predicate | What it means | Why a different client helps |
|---|---|---|
is_media_forbidden_error |
403 on the media fetch — requires both "unable to download video data" and "403" |
The SABR case: that client's format URL is dead on arrival. A different exit IP does not fix it. |
is_format_unavailable_error |
"requested format is not available" |
The manifest came back fine, but nothing in it matched the active client list. Common when cookies drop android/android_vr and only web-family clients remain. |
is_page_reload_error |
"the page needs to be reloaded" — player JS challenge failed |
Retrying identical clients re-runs the same challenge against the same player and fails identically. |
The bot-check message does not belong here. "sign in to confirm you're not a bot" is an IP-reputation problem — it belongs on a proxy or rotation path, not in the client ladder. Classifying it as client-fixable routes genuine bot-checks into pointless client switching and delays the tier that could actually resolve them.
Ordering matters. Check is_media_forbidden_error before your generic IP-block classifier, so the ladder gets first refusal. is_media_forbidden_error deliberately requires both substrings — a bare "403" match would swallow genuine IP-reputation blocks into the ladder, where walking every rung is pure waste.
Everything else — bot-checks, network timeouts — propagates immediately, so your own fallback tier (proxy, cookie rotation, whatever you have) sees them at full speed.
This one is easy to misclassify, and the misclassification is expensive. It superficially resembles a bot-check, so it tends to get lumped into an IP-block marker list — which means every occurrence pays for a proxy round-trip that could never have helped, and can trip a proxy health breaker as a side effect.
The evidence it isn't IP-related: the same cookie account produced the identical error on a direct attempt and through a proxy, seconds apart, across 100+ occurrences in one evening. A different exit IP producing an identical failure is proof a different exit IP was never going to fix it.
YouTube's bot-check message uses a curly apostrophe (U+2019): Sign in to confirm you’re not a bot. Marker strings written by hand use a straight '. A plain substring match between the two never fires, even though they render identically — so a textbook bot-check classifies as False and falls through to the wrong branch.
normalize_error_text flattens typographic punctuation before matching, which kills that entire class of bug for any marker list, not just the one known case.
On exhaustion the original exception is re-raised, not a wrapper, so any error classifier you already have downstream sees exactly the text it expects.
pip install git+https://github.com/dipak8080/ytdlp-client-ladderfrom ytdlp_client_ladder import extract_info_with_retry
info = extract_info_with_retry(
{"format": "bestaudio/best"},
"https://www.youtube.com/watch?v=...",
)Cookie detection is automatic — if ydl_opts has a cookiefile, the cookie ladder is used instead. android and android_vr are dropped there, since yt-dlp silently skips them when cookies are attached, which otherwise leaves web running alone and frequently returning no audio formats at all.
Bound the walk when each rung costs you something:
info = extract_info_with_retry(
ydl_opts, url,
max_client_rungs=2, # e.g. a metered proxy path
)Bring your own ladder:
info = extract_info_with_retry(
ydl_opts, url,
ladder_no_cookies=(["tv_simply"], ["web_embedded"]),
)The default extractor does a single extraction pass (download=False — webpage, every player-client API call, PO token generation, JS challenge solving) and then reuses that same result via process_ie_result(download=True).
The naive pattern — a separate metadata extraction to check duration, followed by a fully independent extraction inside the real download — pays for the entire webpage/player-API/PO-token/JS-challenge sequence twice per request. That's usually the single biggest avoidable chunk of per-request latency. process_ie_result is yt-dlp's own supported API for the "extract once, decide, then download" pattern.
extract_with_backoff is fully synchronous and blocking. Call it from a thread or executor if you're in an event loop.
If you run extraction in a subprocess, runner.run_worker handles the part that trips people up:
from ytdlp_client_ladder import run_worker
await run_worker([worker_script, input_path, output_path], timeout_seconds=300)yt-dlp spawns helper children — Node for PO tokens, Deno for JS challenges, ffmpeg for postprocessing. Those children are what keep burning bandwidth and CPU after a timeout, and they don't die just because their parent does. asyncio.wait_for on a coroutine cannot help: it cancels the await, not the process tree.
run_worker gives the child its own process group (start_new_session=True) and kills the whole group with os.killpg on timeout.
POSIX only. On Windows, use creationflags=subprocess.CREATE_NEW_PROCESS_GROUP and terminate with taskkill /F /T /PID <pid>.
PERMANENT_ERROR_MARKERS is deliberately narrow. A false positive means giving up early on something that might have worked — the expensive direction to be wrong in. Note that "copyright claim" is narrowed from a bare "copyright", which risks matching a message that merely mentions copyright without being a takedown.
CLIENT_FIXABLE_ERROR_MARKERS can afford to be broader. A false positive there just means one wasted rung.
Both are overridable — pass your own sequences to is_permanent and is_client_fixable.
MIT
Extracted from the extraction layer behind AudioForges, a free audio toolkit for producers, DJs, and musicians.