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,
}
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()
},
}
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
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}
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)