Back to blog

Python · youtube-transcript-api · Troubleshooting

youtube-transcript-api RetryError: 'too many 429 error responses'

With a rotating proxy and retries_when_blocked, a 429 can end in requests' RetryError instead of RequestBlocked. Why, which requests are retried, and a fix, tested on youtube-transcript-api 1.2.4.

· 6 min read · YTAPI

You set up youtube-transcript-api with a rotating proxy, so blocked requests retry on a new IP. Then a fetch dies with an error that isn't one of the library's:

requests.exceptions.RetryError: HTTPSConnectionPool(host='www.youtube.com', port=443):
Max retries exceeded with url: /api/timedtext?v=... (Caused by ResponseError('too many 429 error responses'))

The host and path in the message depend on which request failed; in our tests below, the same error named our local test server.

Short answer: YouTube answered a GET request (the watch page or the caption file) with HTTP 429 every time. urllib3 retried it retries_when_blocked times on new connections, ran out, and requests raised RetryError. The library never saw the response, so it couldn't turn it into IpBlocked, and except RequestBlocked doesn't catch it. Catch RetryError too, and wait between rounds; the code is below.

We make YTAPI, a hosted transcript API, so we have an interest here. We read the 1.2.4 source and ran the library against a local stand-in for YouTube that answers with 429s and bot checks on cue, to see exactly what gets retried and what you get back. The test script is at the end; run it yourself. Along the way we checked an open issue, #612, which says a 429 on the player POST is never retried. That's not what we saw.

Three requests per transcript

In version 1.2.4 (the latest release, January 2026), fetch() makes three requests:

  1. GET https://www.youtube.com/watch?v=…, to read the InnerTube API key from the page.
  2. POST https://www.youtube.com/youtubei/v1/player, as the Android client, to get the list of caption tracks.
  3. GET the caption file URL from that list (/api/timedtext).

Every request goes through the same requests session. Without retries, a 429 on any of them becomes IpBlocked, and a player response that says "Sign in to confirm you're not a bot" becomes RequestBlocked. IpBlocked is a subclass of RequestBlocked. With retries on, some 429s end differently, as the tests below show.

Two retry layers

When a proxy config has retries_when_blocked above zero, two things retry, and they don't cover the same requests.

urllib3, at the HTTP level. The library mounts this on the session (_api.py, lines 42–48):

retry_config = Retry(
    total=proxy_config.retries_when_blocked,
    status_forcelist=[429],
)

With no allowed_methods, urllib3 retries only its default idempotent methods, which leave out POST. We checked on urllib3 2.8.0:

>>> r = Retry(total=5, status_forcelist=[429])
>>> r.is_retry("GET", 429), r.is_retry("POST", 429), r.backoff_factor
(True, False, 0)

So the two GETs are retried on a 429, right away (the backoff factor is 0, so there's no pause unless the response carries Retry-After). The player POST is not.

The library itself, around steps 1 and 2. _fetch_captions_json catches RequestBlocked and starts over from the watch page, up to retries_when_blocked times in all. That loop is what the issue misses: a 429 on the POST raises IpBlocked, the loop catches it, and the whole pair of requests runs again on a new connection.

What we saw

We pointed the library at a local server that plays YouTube and the proxy at once, used a proxy config that behaves like WebshareProxyConfig (retries on, Connection: close on every request), set retries_when_blocked=5, and counted what arrived. Every request in every run carried Connection: close, so through a real rotating proxy each one would have had a new IP.

YouTube answers withRequests that reached itWhat your code gets
429 on the player POST, every time5 watch GETs, 5 POSTsIpBlocked
429 on the player POST, once2 watch GETs, 2 POSTs, 1 caption GETthe transcript
Bot check on the player POST, every time5 watch GETs, 5 POSTsRequestBlocked
Bot check on the player POST, once2 watch GETs, 2 POSTs, 1 caption GETthe transcript
429 on the watch page, every time6 watch GETsrequests.exceptions.RetryError
429 on the caption file, every time1 watch GET, 1 POST, 6 caption GETsrequests.exceptions.RetryError

The raw output for the first and the last rows:

youtube-transcript-api 1.2.4, urllib3 2.8.0, retries_when_blocked=5
post429: the player POST always answers 429
result: youtube_transcript_api._errors.IpBlocked
  GET /watch: 5 (Connection: close on 5)
  POST /youtubei/v1/player: 5 (Connection: close on 5)

youtube-transcript-api 1.2.4, urllib3 2.8.0, retries_when_blocked=5
timedtext429: the caption file GET always answers 429
result: requests.exceptions.RetryError
  GET /watch: 1 (Connection: close on 1)
  POST /youtubei/v1/player: 1 (Connection: close on 1)
  GET /api/timedtext: 6 (Connection: close on 6)

What this means for your code

A block on the player request is retried. Not by urllib3, as the issue says, but by the library's own loop, which also fetches the watch page again each time. With retries_when_blocked=5 you get five attempts, each on a new connection, and IpBlocked or RequestBlocked after the last one.

A 429 on a GET ends in a different exception. urllib3 retries the watch page or the caption file retries_when_blocked more times, and when it runs out, requests raises RetryError. The library's IpBlocked check never sees the response, so code that catches only RequestBlocked lets this one through. Plan for this one: the caption file is a separate request, and a 429 there comes after the first two steps have already worked. yt-dlp users know the same pattern as HTTP 429 on subtitles.

Retries happen back to back. Nothing waits between them. That's fine when each retry gets a new IP from a large pool. It doesn't help when the whole pool is flagged. The library's README says YouTube blocks most IPs known to belong to cloud providers, and recommends rotating residential proxies.

Adding POST to allowed_methods changes the error, not much else. We tried the fix proposed in the issue. A single 429 on the POST was retried by urllib3 without fetching the watch page again (one GET and two POSTs instead of two of each). When every POST got a 429, urllib3 tried six times and then raised RetryError instead of IpBlocked, so the library's loop never ran.

A wrapper that handles both

If you run the library yourself, catch both exceptions and wait between rounds:

import random
import time

from requests.exceptions import RetryError
from youtube_transcript_api import RequestBlocked, YouTubeTranscriptApi


def fetch_with_backoff(api: YouTubeTranscriptApi, video_id: str, attempts=4, base=5.0, **kwargs):
    """Retry a blocked fetch a few times, waiting longer each round.
    Returns a FetchedTranscript; call .to_raw_data() for a list of dicts.
    kwargs go to fetch(), e.g. languages=["en"]."""
    for attempt in range(attempts):
        try:
            return api.fetch(video_id, **kwargs)
        except (RequestBlocked, RetryError):
            if attempt == attempts - 1:
                raise
            time.sleep(base * 2**attempt + random.uniform(0, base))

Against the stand-in, with the watch page always answering 429, retries_when_blocked=2 and attempts=3, it made nine requests and then raised RetryError, as it should. With a pool that has clean IPs in it, the pause gives the next round a better chance. Without a proxy, expect to need longer waits: yt-dlp users report needing about a minute between subtitle files.

Two other settings matter:

  • GenericProxyConfig doesn't retry at all: its retries_when_blocked is 0. Only WebshareProxyConfig turns retries on, with 10 by default. To retry through another rotating proxy, subclass GenericProxyConfig as the test script does.
  • Retries only rotate the IP if the connection closes. WebshareProxyConfig does that for you; with your own config, return True from prevent_keeping_connections_alive.

Limits of this test

  • It ran against a stand-in, not YouTube. The stand-in returns the status codes and the bot-check response the library looks for; what YouTube sends a given IP on a given day varies.
  • It covers version 1.2.4 with urllib3 2.8.0 and requests 2.34.2. A later release may change either retry layer; rerun the script after upgrading.
  • We didn't test real proxy providers. Whether a retry gets a clean IP depends on the pool, not the library.

Run it yourself

Save this as retry_test.py, install youtube-transcript-api==1.2.4, and run python retry_test.py <scenario> 5 with one of the scenario names at the top. Unset HTTP_PROXY and HTTPS_PROXY first if your shell has them, so requests reach the local server.

"""What does youtube-transcript-api retry when it is blocked?

Runs the library against a local stand-in for YouTube, which also plays the
proxy, and counts the requests that reach it.

    pip install youtube-transcript-api==1.2.4
    python retry_test.py post429 5
"""
import collections, json, sys, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib.metadata import version

from youtube_transcript_api import YouTubeTranscriptApi, _transcripts
from youtube_transcript_api.proxies import GenericProxyConfig

SCENARIOS = {
    "post429": "the player POST always answers 429",
    "post429x1": "the player POST answers 429 once, then works",
    "bot": "the player POST always answers with the bot check",
    "botx1": "the bot check comes once, then the POST works",
    "watch429": "the watch page GET always answers 429",
    "timedtext429": "the caption file GET always answers 429",
}
if len(sys.argv) < 2 or sys.argv[1] not in SCENARIOS:
    sys.exit("usage: python retry_test.py <scenario> [retries]\nscenarios: " + ", ".join(SCENARIOS))
SCENARIO = sys.argv[1]
RETRIES = int(sys.argv[2]) if len(sys.argv) > 2 else 5
HOST = "http://youtube.test"
hits = collections.Counter()
closed = collections.Counter()
lock = threading.Lock()

WATCH_HTML = '<script>var ytcfg={"INNERTUBE_API_KEY": "testkey"};</script>'
PLAYER_OK = {
    "playabilityStatus": {"status": "OK"},
    "captions": {"playerCaptionsTracklistRenderer": {
        "captionTracks": [{
            "baseUrl": HOST + "/api/timedtext?v=vid",
            "name": {"runs": [{"text": "English"}]},
            "languageCode": "en",
        }],
        "translationLanguages": [],
    }},
}
PLAYER_BOT = {"playabilityStatus": {
    "status": "LOGIN_REQUIRED",
    "reason": "Sign in to confirm you’re not a bot",
}}
CAPTIONS = '<?xml version="1.0"?><transcript><text start="0" dur="1">hello</text></transcript>'


class StandIn(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *args):
        pass

    def send(self, code, body, ctype="text/plain"):
        data = body.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Connection", "close")  # one request per connection
        self.close_connection = True
        self.end_headers()
        self.wfile.write(data)

    def route(self, method):
        self.rfile.read(int(self.headers.get("Content-Length") or 0))
        key = method + " " + self.path.replace(HOST, "").split("?")[0]
        with lock:
            hits[key] += 1
            n = hits[key]
            if self.headers.get("Connection", "").lower() == "close":
                closed[key] += 1
        if key == "GET /watch":
            if SCENARIO == "watch429":
                return self.send(429, "Too Many Requests")
            return self.send(200, WATCH_HTML, "text/html")
        if key == "POST /youtubei/v1/player":
            if SCENARIO == "post429" or (SCENARIO == "post429x1" and n == 1):
                return self.send(429, "Too Many Requests")
            if SCENARIO == "bot" or (SCENARIO == "botx1" and n == 1):
                return self.send(200, json.dumps(PLAYER_BOT), "application/json")
            return self.send(200, json.dumps(PLAYER_OK), "application/json")
        if key == "GET /api/timedtext":
            if SCENARIO == "timedtext429":
                return self.send(429, "Too Many Requests")
            return self.send(200, CAPTIONS, "text/xml")
        return self.send(404, "not found")

    def do_GET(self):
        self.route("GET")

    def do_POST(self):
        self.route("POST")


class RotatingProxy(GenericProxyConfig):
    """Like WebshareProxyConfig: retry when blocked, and close every
    connection so a rotating proxy gives the next request a new IP."""

    @property
    def retries_when_blocked(self):
        return RETRIES

    @property
    def prevent_keeping_connections_alive(self):
        return True


server = ThreadingHTTPServer(("127.0.0.1", 0), StandIn)
threading.Thread(target=server.serve_forever, daemon=True).start()
proxy = f"http://127.0.0.1:{server.server_address[1]}"

# Send the library to the stand-in instead of www.youtube.com.
_transcripts.WATCH_URL = HOST + "/watch?v={video_id}"
_transcripts.INNERTUBE_API_URL = HOST + "/youtubei/v1/player?key={api_key}"

api = YouTubeTranscriptApi(proxy_config=RotatingProxy(http_url=proxy, https_url=proxy))
print(f"youtube-transcript-api {version('youtube-transcript-api')}, "
      f"urllib3 {version('urllib3')}, retries_when_blocked={RETRIES}")
print(f"{SCENARIO}: {SCENARIOS[SCENARIO]}")
try:
    api.fetch("vid")
    print("result: transcript fetched")
except Exception as e:
    print(f"result: {type(e).__module__}.{type(e).__name__}")
for key, n in hits.items():
    print(f"  {key}: {n} (Connection: close on {closed[key]})")

Skipping the proxies

If you'd rather not run proxies at all, YTAPI fetches transcripts through our own proxy network and retries on the server. Errors cost nothing; a transcript costs 1 credit, and new accounts get 200 free:

# pip install ytapi-sdk
import os
from ytapi import YTAPI

api = YTAPI(api_key=os.environ["YTAPI_KEY"])
print(api.get_transcript("dQw4w9WgXcQ", format="text"))

See the transcript docs for formats and languages, and why this library gets blocked on Vercel, Render and Railway for the IP side of the problem.

FAQ

Is issue #612 wrong?

Half right. urllib3 does skip the POST, as it says. But the library catches the IpBlocked that follows and starts the fetch again, so in our runs a 429 on the POST was tried retries_when_blocked times, not once.

Why do I get RetryError instead of IpBlocked?

The 429 came on a GET: the watch page or the caption file. urllib3 retried it and gave up before the library could turn the response into IpBlocked. Catch requests.exceptions.RetryError alongside RequestBlocked.

Does retries_when_blocked work without a proxy?

Only with a proxy config, since that's where the setting lives, and only usefully with a rotating one. Without a new IP, retrying the same address a few milliseconds later gets the same answer.

Published October 14, 2026. Tested the same day with youtube-transcript-api 1.2.4, urllib3 2.8.0, requests 2.34.2 and Python 3.10.