Skip to content

yt-dlp HTTP 429: Too Many Requests (YouTube Rate Limiting)

Problem

yt-dlp fails with a 429 — most often on the subtitle step, sometimes on video formats:

ERROR: Unable to download video subtitles for 'en-en-US': HTTP Error 429: Too Many Requests

Subtitles are usually the first thing to break because YouTube's timedtext (subtitle) endpoint has its own, much stricter per-IP rate bucket than the video/audio CDN. A download can fetch the full video fine and then 429 only on the .vtt.

There are two distinct causes, and the error output tells you which one you have.


Diagnosis: which case is this?

Look for this warning immediately before the 429:

WARNING: The extractor specified to use impersonation for this download,
but no impersonate target is available.
  • Warning present → Case 1: broken impersonation. yt-dlp is being fingerprinted as a bot and rate-limited aggressively.
  • No warning, 429 on subtitles only → Case 2: hot timedtext bucket. Impersonation is fine; you've simply made too many subtitle requests from this IP recently.

Confirm impersonation health directly:

yt-dlp --list-impersonate-targets | head -5

Real entries (Chrome-133 Macos-15 curl_cffi) = impersonation working. An (unavailable) table = Case 1.


Case 1: Broken Impersonation (curl_cffi)

Cause: yt-dlp needs curl_cffi to impersonate a real browser's TLS fingerprint. Without it (or with an unsupported version), YouTube detects the non-browser client and rate-limits with 429s.

Fix (pin curl_cffi to the supported range):

pip3 install --user -U "curl_cffi>=0.10,<0.15" "yt-dlp-ejs>=0.8"

⚠️ Do not run a bare pip install -U curl_cffi. As of yt-dlp 2026.03.17, the backend in yt_dlp/networking/_curlcffi.py hard-caps at 0.14.x:

ImportError: Only curl_cffi versions 0.5.10 and 0.10.x through 0.14.x are supported

Installing curl_cffi 0.15.0 silently disables impersonation — yt-dlp --list-impersonate-targets will show every source as (unavailable) even though import curl_cffi works fine. Always pin to <0.15 until yt-dlp widens the range.

Verify:

yt-dlp --list-impersonate-targets | head -5

Should now show real entries, not the (unavailable) table.


Case 2: Hot timedtext Bucket (subtitles only)

Cause: the subtitle endpoint's per-IP quota is already exhausted — typically from a batch of downloads or from retrying a failed download several times (each retry re-hits timedtext). Impersonation is healthy; the video/audio path is unaffected.

Check the auto-vs-manual split first

Auto-translated captions and manually-authored subtitle tracks are rate-limited separately, and the auto path is far more aggressive. A video can 429 forever on its auto track while its manual track downloads instantly. Waiting does not help in that case — you are waiting on a bucket that was never the problem. Always run the zero-cost check below before deciding to wait.

Fix — try these in order:

# 0. FIRST -- identify which track is actually blocked. --list-subs fetches the
#    player response only, NOT timedtext, so it costs zero requests against the bucket.
yt-dlp --list-subs --skip-download https://www.youtube.com/watch?v=VIDEO_ID
#    "Available subtitles:"        -> manually-authored tracks (e.g. en-US)
#    "available automatic captions" -> auto/auto-translated (e.g. en-en-US)
#
#    If a manual track exists, ask for it explicitly. --write-auto-subs alone will
#    take the auto path even when a real track is sitting right there:
yt-dlp --skip-download --write-subs --no-write-auto-subs \
       --sub-langs "en-US" https://www.youtube.com/watch?v=VIDEO_ID

# 1. Only if the manual track is also blocked (or does not exist):
#    wait 15-60 min and retry. NOTE: not reliable -- see occurrence #2, where a
#    34-minute cooldown changed nothing because the auto track was the blocked one.

# 2. Retry now, but throttle subtitle requests:
yt-dlp --sleep-subtitles 5 https://www.youtube.com/watch?v=VIDEO_ID

# 3. Skip subtitles for this download (finishes cleanly if the video already downloaded):
yt-dlp --no-write-subs --no-write-auto-subs https://www.youtube.com/watch?v=VIDEO_ID

Reproducing the auto-vs-manual split

A controlled A/B on a single video — same IP, same client, minutes apart (majorhome, 2026-08-22, M_G_f5g47wI):

URL="https://www.youtube.com/watch?v=VIDEO_ID"

# Which tracks exist? Costs ZERO timedtext requests.
yt-dlp --list-subs --skip-download "$URL"
#   "Available subtitles:"          en-US                  <- manually authored
#   "available automatic captions:" en-en-US, es-en-US, ... <- auto / auto-translated

# A -- manual track
yt-dlp --skip-download --write-subs --no-write-auto-subs \
       --sub-langs "en-US" -o "/tmp/subtest/%(title)s.%(ext)s" "$URL"
#   [info] Downloading subtitles: en-US
#   [download] 100% of 23.49KiB          <- SUCCESS

# B -- auto track, ~5 minutes later, same IP, same session
yt-dlp --skip-download --write-subs --write-auto-subs \
       --sub-langs "es-en-US" -o "/tmp/subtest/%(title)s.%(ext)s" "$URL"
#   [info] Downloading subtitles: es-en-US        <- selection is correct
#   ERROR: HTTP Error 429: Too Many Requests      <- YouTube refuses the auto path

Two things fall out of B:

  • The per-IP bucket was not exhausted. If it had been, A would have failed too — it ran minutes earlier against the same endpoint family from the same address and returned instantly. Only the auto path is limited.
  • --write-subs --write-auto-subs still selects auto tracks correctly. B picked es-en-US and only then hit the 429, so adding --write-subs does not break videos that have auto captions and nothing else. It changes which track is preferred, not which are reachable.

What this does not fix

On a video with only auto captions, there is no manual track to fall back to, so it will still 429 whenever the auto bucket is hot. Preferring manual tracks narrows the failure surface; it does not remove it.

If you batch-download regularly, make the throttle permanent in ~/.config/yt-dlp/config:

--sleep-subtitles 5

✅ Done on majorhome 2026-08-22 — --sleep-subtitles 5 is now permanent in ~/.config/yt-dlp/config (backup: config.bak.2026-08-22). The ytdl two-pass wrapper makes this mandatory rather than optional on that box: pass 2 (--skip-download --write-auto-subs) hits timedtext on every invocation, so even non-batch use drains the bucket as soon as a video is retried.


Occurrences

#1 — majorhome, 2026-07-21: a single-video download fetched formats 137+140 successfully, then 429'd only on the en-en-US subtitle. --list-impersonate-targets showed healthy targets and curl_cffi 0.14.0 was pinned correctly — classic Case 2. Waiting and retrying resolved it.

#2 — majorhome, 2026-08-22: same signature, second occurrence, which promotes the throttle from a suggestion to the default (rule of two). ytdl was run twice within ~2 min on one video (M_G_f5g47wI); pass 1 completed the 182 MB mp4, pass 2 429'd on en-en-US and left no .vtt sidecar. Impersonation healthy (Chrome-133/136 targets, curl_cffi 0.14.0 pinned).

The documented Case 2 remedy did not work here, and that is the lesson. A 34-minute cooldown plus --sleep-subtitles 5 produced an identical 429 on the second attempt. What actually resolved it: --list-subs (zero timedtext cost) showed the video had a manual en-US track in addition to the auto-translated en-en-US the wrapper was requesting. Asking for en-US explicitly downloaded 23.5 KiB / 308 cues instantly, with no 429, while the auto track was still blocked. The per-IP bucket was never exhausted — only the auto-caption path was limited. The mp4 never needed re-fetching, since Plex reads the .vtt sidecar natively.

Practical consequence: --write-auto-subs on its own is the wrong default. It takes the auto path even when a better, less-throttled manual track exists. Prefer requesting both (--write-subs --write-auto-subs), which downloads the manual track when present and falls back to auto only when it is not.

⚠️ Noise to ignore in that output: WARNING: [youtube] [pot:bgutil:http] Error reaching GET http://127.0.0.1:4416/ping. yt-dlp probes the HTTP PO-token provider first, fails, and falls back to script-deno, which succeeds on the very next line. The warning is unrelated to the 429 either way.

SUPERSEDED 2026-08-28 — the "by design" half of this note was wrong. It used to read "majorhome uses the bgutil script provider by design — there is no HTTP server, container, or systemd unit for it." Nothing was listening on 4416 because the server had simply never been deployed, not because script mode was chosen. A bgutil-pot systemd user service now runs the matching server (tag 1.3.2, prebuilt server/build/main.js) from the clone already at ~/bgutil-ytdlp-pot-provider, so a healthy majorhome run now logs [pot:bgutil:http] Generating a gvs PO Token ... via bgutil HTTP server and no warning at all. If you see the 4416 warning on majorhome today, it is a real fault — check systemctl --user status bgutil-pot. See the vault runbook yt-dlp-po-token-majorhome (MajorInfrastructure/Runbooks — vault-only, not published to this wiki).

#3 — majorhome, 2026-08-28: third occurrence, and the first where neither documented remedy applies. ytdl on hJwkm64TYxY completed pass 1 with "has already been downloaded" and then 429'd on track en. The occurrence #2 fix — prefer the manual track — has nothing to prefer here: --list-subs reports hJwkm64TYxY has no subtitles, only automatic captions. Auto-only videos have no fallback, and waiting is already known to be unreliable on the auto bucket (#2). On a video like this, the honest answer is that the sidecar may simply not be obtainable while the bucket is hot. The mp4 is unaffected and plays fine; Plex just has no captions for it.

The useful finding was structural, not about this video. The ytdl wrapper chained its two passes with a bare &&, so pass 2 fired on every invocation — including ones where pass 1 did nothing. Re-running ytdl on an already-complete download printed "has already been downloaded", exited 0, and still sent a timedtext request for a sidecar that in the normal case was already on disk. Every retry of a finished download spent a request against the tightest bucket YouTube gives us, which is precisely the drain the --sleep-subtitles note above was trying to slow down.

Fixed the same day: pass 1 now reports its final media path via --print-to-file after_move:filepath, and pass 2 is skipped when every file it names already has a .vtt/.srt sidecar. after_move:filepath is populated even on the "already downloaded" branch (verified on this video), and unlike capturing stdout it leaves the progress display on a real tty. Re-running a complete download now prints ytdl: subtitle sidecar already present — skipping subtitle pass and exits without touching the network.

Also cleared out in the same pass: --write-auto-subs and --embed-subs were still sitting in ~/.config/yt-dlp/config even though the wrapper negates both. They were inert for ytdl but re-armed 403 trigger #1 for any bare yt-dlp call, so both were removed (config.bak-2026-08-28). Checked first that nothing else depended on them — yt-dlp-watchdog.sh runs --ignore-config and yt-dlp-hevc-convert.sh is an --exec hook that never invokes yt-dlp.

Confound ruled out: the bgutil HTTP server was deployed during this same session, and the 429 reproduced identically after the 4416 warning disappeared. PO-token provider health and the timedtext bucket are independent — do not read a fixed bgutil warning as a fixed 429, or vice versa.