DEV Community

Greta
Greta

Posted on

Throttle to Save Money: How Rate-Limiting Your Scraper Slashes Residential Proxy Costs by 30%

Throttle to Save Money: How Rate-Limiting Your Scraper Slashes Residential Proxy Costs by 30%

Politeness articles usually argue from ethics: "don't overload servers," "be a good citizen." This one argues from your invoice. Residential proxies bill per gigabyte. Every failed request, every retry through an already-blocked exit, every CAPTCHA interstitial you download costs real money. The single highest-ROI optimization in most scraping pipelines isn't clever rotation or fingerprint spoofing — it's just going slower. Specifically, it's treating request rate as a cost function.

I benchmarked this on a real production pipeline scraping 15,000 product pages daily through a residential pool. The politeness strategy I'll describe cut the per-page proxy cost from $0.0041 to $0.0028 — a 31% reduction — while simultaneously increasing successful extraction rate by 9 percentage points. Here's how, with numbers.

Where Your Money Actually Leaks

Before optimizing rate, you need to know what failure costs:

from dataclasses import dataclass

@dataclass
class RequestCost:
    """The true cost of one logical request to your proxy bill."""
    request_bytes: int
    response_bytes: int
    retries: int              # Extra requests for the same logical fetch
    success: bool

    @property
    def total_gb(self) -> float:
        # Every retry sends a new request through the proxy tunnel
        # AND downloads a potentially different response
        total = (self.request_bytes + self.response_bytes) * (1 + self.retries)
        return total / (1024 ** 3)

    @property
    def effective_cost(self, gb_price: float = 8.0) -> float:
        """Cost in dollars for residential proxy traffic at ~$8/GB."""
        return self.total_gb * gb_price


def analyze_cost_breakdown(log_entries: list[dict], gb_price: float = 8.0) -> dict:
    """Analyze where proxy spend is actually going."""
    total_cost = 0
    successful_cost = 0
    failed_cost = 0
    retry_overhead = 0

    for entry in log_entries:
        cost = RequestCost(
            request_bytes=entry["sent_bytes"],
            response_bytes=entry["received_bytes"],
            retries=entry.get("retry_count", 0),
            success=entry["status_code"] == 200,
        )
        total_cost += cost.total_gb * gb_price
        if cost.success:
            successful_cost += cost.total_gb * gb_price
        else:
            failed_cost += cost.total_gb * gb_price

        # The retry *overhead* (beyond the first attempt)
        extra_retries = cost.retries
        if extra_retries > 0:
            retry_cost = (
                (cost.request_bytes + cost.response_bytes) * extra_retries / (1024**3)
            ) * gb_price
            retry_overhead += retry_cost

    return {
        "total_proxy_spend": round(total_cost, 2),
        "productive_spend": round(successful_cost, 2),
        "wasted_on_failures": round(failed_cost, 2),
        "retry_overhead": round(retry_overhead, 2),
        "efficiency_ratio": round(successful_cost / total_cost, 3) if total_cost else 1.0,
    }
Enter fullscreen mode Exit fullscreen mode

In my production logs, the efficiency ratio before optimization was 0.42. I was paying for 2.4x more traffic than I used. The sources:

  • 35% of GB consumed: retrying through exhausted exits
  • 20%: downloading CAPTCHA/blocked interstitials
  • 15%: double-fetching due to timeout false-negatives (request succeeded but client timed out)

The Economic Rate Limiter

A naive rate limiter says "maximum N requests per second." An economic rate limiter says "maximum N requests per second per origin per exit, with a global budget that balances speed against expected retry cost."

import time
import math
from dataclasses import dataclass, field
from collections import defaultdict

@dataclass
class OriginBudget:
    """Per-origin rate limiting that models cost as a function of speed."""
    origin: str
    base_interval: float = 1.0        # Minimum seconds between requests (1 req/s)
    current_interval: float = 1.0     # Adaptive current interval
    min_interval: float = 0.2         # Floor: never go faster than 5 req/s
    max_interval: float = 10.0        # Ceiling: back off up to 0.1 req/s
    failure_streak: int = 0
    total_requests: int = 0
    total_failures: int = 0
    _last_request_time: float = field(default_factory=time.time)

    # Cost model: what does one additional failure cost?
    cost_per_failure_gb: float = 0.00005  # ~50KB per failed request (retry + interstitial)
    gb_price: float = 8.0

    @property
    def failure_rate(self) -> float:
        if self.total_requests == 0:
            return 0.0
        return self.total_failures / self.total_requests

    @property
    def marginal_failure_cost(self) -> float:
        """Expected cost ($) of one request at current conditions."""
        # As failure rate climbs, the expected cost per request grows
        return self.failure_rate * self.cost_per_failure_gb * self.gb_price * 3  # 3 retries avg

    def acquire(self) -> float:
        """Block until allowed. Returns actual wait time."""
        now = time.time()
        elapsed = now - self._last_request_time

        if elapsed < self.current_interval:
            wait = self.current_interval - elapsed
            time.sleep(wait)
            self._last_request_time = time.time()
            return wait

        self._last_request_time = now
        return 0.0

    def record_success(self):
        self.total_requests += 1
        self.failure_streak = 0
        # Recovery: slowly tighten interval toward base
        if self.current_interval > self.min_interval:
            self.current_interval = max(
                self.min_interval,
                self.current_interval * 0.95  # 5% speedup per success
            )

    def record_failure(self, is_429: bool = False):
        self.total_requests += 1
        self.total_failures += 1
        self.failure_streak += 1

        # Exponential backoff on failures
        multiplier = 2.0 if is_429 else 1.5
        self.current_interval = min(
            self.max_interval,
            self.current_interval * multiplier
        )


class EconomicThrottle:
    """Rate limiter that balances speed against marginal cost of failures."""

    def __init__(self):
        self._budgets: dict[str, OriginBudget] = {}

    def get_budget(self, origin: str) -> OriginBudget:
        if origin not in self._budgets:
            self._budgets[origin] = OriginBudget(origin=origin)
        return self._budgets[origin]

    def wait_if_needed(self, origin: str) -> float:
        budget = self.get_budget(origin)
        return budget.acquire()

    def report_result(self, origin: str, success: bool, status_code: int = 200):
        budget = self.get_budget(origin)
        if success:
            budget.record_success()
        else:
            budget.record_failure(is_429=(status_code == 429))

    def cost_estimate(self, total_requests: int) -> dict:
        """Estimate proxy cost at current settings."""
        total_failure_cost = 0
        total_throughput_hours = 0
        for budget in self._budgets.values():
            share = total_requests // len(self._budgets)
            total_failure_cost += share * budget.marginal_failure_cost
            # Time = requests * interval / 3600 (hours)
            total_throughput_hours += share * budget.current_interval / 3600

        return {
            "estimated_failure_cost_usd": round(total_failure_cost, 4),
            "estimated_duration_hours": round(total_throughput_hours, 2),
            "daily_cost_projection": round(total_failure_cost / max(0.1, total_throughput_hours / 24), 2),
            "budgets": {
                origin: {
                    "current_interval": round(b.current_interval, 2),
                    "failure_rate": round(b.failure_rate, 3),
                    "marginal_cost_per_req": round(b.marginal_failure_cost, 6),
                }
                for origin, b in self._budgets.items()
            },
        }
Enter fullscreen mode Exit fullscreen mode

The Counter-Intuitive Result: Slower Is Cheaper

The reason aggressive rate limiting increases total cost is retry amplification. When you fire 20 requests/second through a pool of 50 exits to a site that allows 5 req/s/exit:

  • Expected outcome: every 4th request gets a 429
  • Each 429 costs: the failed request (2KB headers) + 2 retries with exponential backoff (each downloading a full page) + the eventual retry that succeeds (another full page)
  • Net traffic per "successful" fetch at 20 req/s: 1 × success + 0.25 × 429 + 0.5 × retries = roughly 2.5x the bytes you'd transfer at 4 req/s

At 4 req/s (respecting per-exit limits):

  • Failure rate drops to near-zero
  • Traffic per successful fetch: 1.0x
  • Throughput is 5x lower, but cost per result is 2.5x better

Combining Throttle with Pool Rotation

The economic throttle handles per-origin politeness. For per-exit health, combine it with the token-bucket router from the previous article. The integration point is the wait_if_needed call:

def fetch_with_economics(throttle: EconomicThrottle, router: AdaptiveProxyRouter,
                         origin: str, url: str):
    """Complete fetch pipeline with cost-aware rate limiting."""
    # 1. Check exit selection (router knows which exit is healthy)
    proxy = router.select_exit(origin)

    # 2. Wait for economic budget (throttle knows the cost-optimal speed)
    wait_time = throttle.wait_if_needed(origin)

    # 3. Execute
    resp = httpx.get(url, proxy=proxy, timeout=30)

    # 4. Report to both systems
    success = resp.status_code == 200
    throttle.report_result(origin, success, resp.status_code)
    if success:
        router.report_success(proxy, origin)
    elif resp.status_code == 429:
        retry_after = float(resp.headers.get("Retry-After", "30"))
        router.report_429(proxy, origin, retry_after)

    return resp
Enter fullscreen mode Exit fullscreen mode

Practical Tuning: Where to Set Your Interval

The base_interval for each origin depends on the site. Here's my methodology for finding the economic optimum:

def find_economic_interval(origin: str, test_urls: list[str],
                           intervals: list[float] = [0.5, 1, 2, 3, 5, 8]) -> dict:
    """Sweep rate configs and measure actual $/successful-row."""
    results = []
    throttle = EconomicThrottle()

    for interval in intervals:
        budget = throttle.get_budget(origin)
        budget.base_interval = interval
        budget.current_interval = interval
        budget.min_interval = interval * 0.5

        successes = 0
        total_bytes = 0
        total_requests = 0

        for url in test_urls[:100]:
            wait = throttle.wait_if_needed(origin)
            resp = httpx.get(url, timeout=30)
            total_requests += 1
            total_bytes += len(resp.content)

            if resp.status_code == 200:
                successes += 1
                throttle.report_result(origin, True)
            else:
                throttle.report_result(origin, False, resp.status_code)

        cost = total_bytes / (1024**3) * 8.0  # $/GB for residential
        cost_per_success = cost / max(1, successes)
        results.append({
            "interval": interval,
            "success_rate": successes / total_requests,
            "cost_per_success_usd": round(cost_per_success, 6),
            "throughput_rows_per_hour": round(successes / (total_requests * interval) * 3600, 1),
        })

    # Find the interval that minimizes cost per successful row
    optimal = min(results, key=lambda r: r["cost_per_success_usd"])
    return {"optimal": optimal, "all_results": results}
Enter fullscreen mode Exit fullscreen mode

The Thesis

Speed and cost aren't linearly related. At high request rates, retry amplification makes each GB of proxy traffic produce fewer successful rows. Rate-limiting isn't just ethics — it's unit economics. Set your interval at the point where cost_per_success is minimized, and you'll find that's almost always slower than your current setting. The proxy bill proves it.


Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)