<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: elvin 12</title>
    <description>The latest articles on DEV Community by elvin 12 (@elvin-dev).</description>
    <link>https://dev.to/elvin-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4109517%2F008d01e2-cf1b-4668-a147-f35831007382.png</url>
      <title>DEV Community: elvin 12</title>
      <link>https://dev.to/elvin-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elvin-dev"/>
    <language>en</language>
    <item>
      <title>What 8,000 conversions a day taught me about proxy rotation</title>
      <dc:creator>elvin 12</dc:creator>
      <pubDate>Sun, 06 Sep 2026 19:11:58 +0000</pubDate>
      <link>https://dev.to/elvin-dev/what-8000-conversions-a-day-taught-me-about-proxy-rotation-emh</link>
      <guid>https://dev.to/elvin-dev/what-8000-conversions-a-day-taught-me-about-proxy-rotation-emh</guid>
      <description>&lt;p&gt;I run a small audio conversion tool. Nothing clever — you paste a link, it pulls the audio, you download it. It handles somewhere around 8,000 jobs a day now, and about 91% of the people who start a conversion actually finish the download.&lt;/p&gt;

&lt;p&gt;Getting there took five months and four bugs I would not have found in a test environment. This is a write-up of those four.&lt;/p&gt;

&lt;p&gt;Stack, for context: Flask, Redis, RQ workers, yt-dlp, and a pool of rotating proxies.&lt;/p&gt;

&lt;p&gt;The setup&lt;/p&gt;

&lt;p&gt;Requests hit a queue. Workers pick them up, choose a proxy from a weighted pool, and stream the file to disk while the user's download is already in progress. If a proxy fails, the job retries on the next one.&lt;/p&gt;

&lt;p&gt;That last part matters. The retry chain is what gets success rate from about 92% to about 99%. It is also where three of these four bugs were hiding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: two proxies that were secretly one proxy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had two entries in the pool that looked like this:&lt;/p&gt;

&lt;p&gt;gateway-a.provider.example:PORT_1&lt;br&gt;
gateway-a.provider.example:PORT_2&lt;/p&gt;

&lt;p&gt;Same host, different ports. Different exit IPs when I tested them individually, so I treated them as independent.&lt;/p&gt;

&lt;p&gt;They were not. They share an upstream gateway. When one got rate-limited, the other was already in trouble — but my code kept routing traffic to it, because it was keying cooldowns and slot limits on the index in the pool array, and index 2 still looked healthy.&lt;/p&gt;

&lt;p&gt;The fix was one line — key on the thing that actually enforces the limit:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
def proxy_key(proxy):&lt;br&gt;
    # Same gateway, different port = same upstream limit.&lt;br&gt;
    # Keying on the pool index treats them as independent,&lt;br&gt;
    # so a cooldown on one never reaches the other.&lt;br&gt;
    return f"{proxy['host']}:{proxy['port']}"&lt;/p&gt;

&lt;p&gt;Lesson: your identity function for a resource has to match whatever enforces the limit, not whatever is convenient in your data structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: rotation running inside the request path&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rotating an exit IP on this kind of proxy means asking the upstream to cycle. That call takes 25 to 36 seconds.&lt;/p&gt;

&lt;p&gt;I had it inside the job. So roughly once every N jobs, one unlucky user waited half a minute extra while a rotation completed.&lt;/p&gt;

&lt;p&gt;It never showed up in the median. It sat in p95, and I spent a while blaming yt-dlp for it.&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;

&lt;h1&gt;
  
  
  Rotation is maintenance, not part of serving a request.
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Inside the job it blocked one unlucky user for 25-36s and
&lt;/h1&gt;

&lt;h1&gt;
  
  
  only ever showed up in p95, never the median.
&lt;/h1&gt;

&lt;p&gt;threading.Thread(target=_rotate_if_due, args=(cfg,), daemon=True).start()&lt;/p&gt;

&lt;p&gt;Lesson: if a maintenance operation is slow and periodic, it does not belong in the request path — even if it is "only sometimes."&lt;br&gt;
**&lt;br&gt;
Bug 3: the fallback that picked the worst option**&lt;/p&gt;

&lt;p&gt;When every slot on a proxy was busy, my code fell back to the next one:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
fallback = (proxy_index + 1) % len(PROXIES)&lt;/p&gt;

&lt;p&gt;Looks harmless. It is not.&lt;/p&gt;

&lt;p&gt;The pool still contained entries I had deliberately disabled by setting their weight to zero. Weight zero kept them out of normal selection — but modular arithmetic does not know about weights. Under load, the fallback path was routing traffic to a proxy that was failing essentially every request.&lt;/p&gt;

&lt;p&gt;python&lt;/p&gt;

&lt;h1&gt;
  
  
  Weight-0 entries are disabled on purpose. Modular arithmetic
&lt;/h1&gt;

&lt;h1&gt;
  
  
  doesn't know that, so pick only from the trusted set.
&lt;/h1&gt;

&lt;p&gt;fallback = next_trusted(proxy_index, TRUSTED_PROXY_INDICES)&lt;/p&gt;

&lt;p&gt;Lesson: I had two mechanisms for "this resource is disabled" — a weight and a list — and only one of them was consulted on the error path. If you disable something, disable it everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 4: retrying things that were never going to work&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one had the biggest payoff and the least cleverness.&lt;/p&gt;

&lt;p&gt;My retry chain gave every failure three attempts across three different proxies. Reasonable for a timeout or a rate limit. Completely pointless for:&lt;/p&gt;

&lt;p&gt;video deleted&lt;br&gt;
video private&lt;br&gt;
age-restricted&lt;br&gt;
members-only&lt;/p&gt;

&lt;p&gt;No proxy on earth fixes a deleted video. But I was spending three attempts and roughly eight seconds finding that out, on about 2% of all jobs.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
PERMANENT = (&lt;br&gt;
    "Video unavailable", "Private video", "This video has been removed",&lt;br&gt;
    "age-restricted", "members-only", "Sign in to confirm",&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;def is_permanent_error(msg: str) -&amp;gt; bool:&lt;br&gt;
    # No proxy fixes a deleted video. Retrying it three times&lt;br&gt;
    # burns ~8s and occupies a slot on every other proxy too.&lt;br&gt;
    return any(p.lower() in msg.lower() for p in PERMANENT)&lt;/p&gt;

&lt;p&gt;Two things improved at once. The user gets a clear answer in about three seconds instead of eight. And the rest of the pool stops being occupied by jobs that were always going to fail — so everyone else's p95 drops as well.&lt;/p&gt;

&lt;p&gt;Lesson: a retry policy that does not distinguish permanent from transient failures is not a retry policy. It is a delay.&lt;/p&gt;

&lt;p&gt;Where it ended up&lt;br&gt;
    Before  After&lt;br&gt;
Success rate    ~92%    99.3%&lt;br&gt;
Median  ~11s    6.6s&lt;br&gt;
p95 30-60s  ~17s&lt;br&gt;
Under 10s   ~60%    89%&lt;/p&gt;

&lt;p&gt;Not all of that is the four bugs — some of it is simply better proxies. But the p95 numbers are almost entirely bugs 2 and 4.&lt;/p&gt;

&lt;p&gt;T*&lt;em&gt;wo things I got wrong for a while&lt;/em&gt;*&lt;/p&gt;

&lt;p&gt;Measuring per job instead of per attempt. I was computing proxy success rates from job outcomes. But one job can touch three proxies. A good proxy cleaning up after a bad one looked identical to a proxy that succeeded on the first try. Once I counted per attempt, one entry turned out to be sitting at 82% while I thought the pool was uniform.&lt;/p&gt;

&lt;p&gt;Trusting one-hour windows. Success rate swings several points hour to hour. I made at least two changes based on a one-hour sample that a 24-hour sample would have told me not to make.&lt;/p&gt;

&lt;p&gt;One last thing, if you are running yt-dlp in production: update it weekly. I have a cron job that pulls the latest build every Monday and restarts workers. Pinning felt safer right up until the first time an extractor broke mid-week and we sat down until someone noticed.&lt;/p&gt;

&lt;p&gt;The tool this runs on is &lt;a href="https://yttowav.net/" rel="noopener noreferrer"&gt;https://yttowav.net/&lt;/a&gt;, if you want to see what the latency actually feels like.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>python</category>
      <category>sre</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
