Every scraping architecture diagram I have ever seen draws the proxy tier as a single box: "residential pool," occasionally split into "datacenter for cheap domains, residential for hard ones." That box is a lie by omission. The tier decision is not a property of your architecture — it is a property of every individual request, and it changes hour by hour as defense levels shift, pools degrade, and prices move.
My argument: tier selection should be a runtime decision made by feedback, not an upfront decision made by a diagram. And the right tool for the job is older than web scraping itself — a multi-armed bandit. Treat datacenter, ISP, and residential exits as three slot machines with different payout distributions, let each request pull a lever, and let the router learn which lever pays for which class of URL. You keep datacenter prices wherever datacenter actually works, and you stop burning your most expensive exits on origins that never needed them.
Why static tier mapping fails quietly
The usual failure mode is invisible overspend. Someone gets blocked on a domain, escalates to residential "to be safe," and never downgrades. Six months later, an audit shows 80% of residential traffic going to origins that would have served a datacenter exit happily. The reverse failure is worse: a domain that used to tolerate datacenter IPs tightens its rules, your success rate on that origin decays from 97% to 71% over two weeks, and nobody notices because the per-origin metric was never computed — only the pool-wide one, which a thousand healthy domains kept green.
A tier map frozen in config has no feedback loop. A bandit router does, and it costs surprisingly little code.
The feedback signal has to be honest
Before the router can learn, you need a reward definition that resists gaming. I use a simple one: reward = 1 for a 2xx/3xx response with parseable content, 0 otherwise, minus a cost penalty. The cost penalty is what makes the bandit interesting. Without it, the router eventually learns "residential always works" and sends everything there — technically optimal for success rate, financially ruinous. With it, the router balances reliability against the real price difference between a datacenter GB and a residential GB.
One engineering detail matters more than the algorithm: the reward belongs to the (tier, origin-class) pair, not to the request. A 403 from a datacenter exit on a hardened sneaker domain teaches you nothing about datacenter exits on a public API. Group origins into classes — by domain, or better, by an origin fingerprint like (domain, defense-tier guess, content type) — and keep independent bandit state per class.
A UCB1 router in Python
UCB1 is my default over epsilon-greedy for this job because it has a clean optimism property: an under-explored tier keeps getting occasional traffic, so if a domain's defense level changes, the router re-explores without you telling it to. Here is a self-contained router you can drop into an existing requests-based pipeline:
import math
import time
from collections import defaultdict
from dataclasses import dataclass, field
TIERS = ["datacenter", "isp", "residential"]
# relative price per successful request; tune to your own plan
TIER_COST = {"datacenter": 0.05, "isp": 0.20, "residential": 1.00}
@dataclass
class Arm:
pulls: int = 0
reward_sum: float = 0.0
@property
def mean(self) -> float:
return self.reward_sum / self.pulls if self.pulls else 0.0
@dataclass
class BanditRouter:
explore_c: float = 0.7 # optimism weight
warmup: int = 5 # pulls per arm before trusting stats
state: dict = field(default_factory=lambda: defaultdict(lambda: {t: Arm() for t in TIERS}))
def select(self, origin_class: str) -> str:
arms = self.state[origin_class]
total = sum(a.pulls for a in arms.values())
best_tier, best_score = None, -math.inf
for tier, arm in arms.items():
if arm.pulls < self.warmup:
return tier # guarantee minimum exploration
# upper confidence bound on reward, cost-penalized
ucb = arm.mean + self.explore_c * math.sqrt(2 * math.log(total) / arm.pulls)
score = ucb - TIER_COST[tier]
if score > best_score:
best_tier, best_score = tier, score
return best_tier
def update(self, origin_class: str, tier: str, success: bool) -> None:
arm = self.state[origin_class][tier]
arm.pulls += 1
arm.reward_sum += (1.0 if success else 0.0) - TIER_COST[tier]
Wiring it into a fetch loop:
import requests
import functools
router = BanditRouter()
GATEWAYS = {
"datacenter": "http://dc-user:dc-pass@gate.proxy.example:7000",
"isp": "http://isp-user:isp-pass@gate.proxy.example:7100",
"residential":"http://res-user:res-pass@gate.proxy.example:7200",
}
def origin_class(url: str) -> str:
# start with the domain; refine to (domain, endpoint family) later
return url.split("/")[2]
def fetch(url: str) -> str:
oclass = origin_class(url)
tier = router.select(oclass)
try:
r = requests.get(
url,
proxies={"http": GATEWAYS[tier], "https": GATEWAYS[tier]},
timeout=20,
headers={"User-Agent": "Mozilla/5.0 (compatible; collector/1.0)"},
)
ok = r.status_code < 400 and r.text # crude "parseable" check
except requests.RequestException:
ok = False
router.update(oclass, tier, bool(ok))
if not ok:
raise
return r.text
# after a few hundred requests the state converges; inspect it:
for oclass, arms in router.state.items():
ranked = sorted(arms.items(), key=lambda kv: -kv[1].mean)
print(oclass, "→", ranked[0][0],
f"(means: { {t: round(a.mean, 2) for t, a in arms.items()} })")
Note what the router never does: it never assumes a 403 means the origin hates the tier forever. UCB1's confidence term decays as pulls accumulate, so a tier that got burned early still gets re-sampled occasionally. That is exactly the behavior you want when origin defense levels drift, because they drift in both directions — domains relax their rules too.
The three failure modes you must handle anyway
A bandit is not a substitute for engineering hygiene; it compounds whatever reward signal you feed it.
Retries poison the signal. If your retry loop re-pulls the same URL through a different tier and eventually succeeds, do not record two updates. Record one outcome per logical fetch, attributed to the tier that produced the final result — or better, record each attempt with its own reward and let the cost penalty naturally capture the waste. Either way, decide once and encode it, or your means will be systematically pessimistic.
Upstream outages look like tier failures. If an origin goes down hard, all three tiers fail, and the router correctly concludes "all arms are bad" — but you should detect that case separately (three-tier simultaneous failure with connection-level errors is an origin outage signature, not a routing problem) and pause the origin instead of burning explore traffic against a dead site.
Cold start per new origin class. A brand-new domain starts with all arms at zero pulls. That is fine for a small fleet, but if you onboard fifty domains a day, consider seeding new origin classes from a global prior: copy the mean of the cheapest arm from a "similar" class (same registrable domain family, same observed defense vendor in the HTML) as the initial reward_sum with a small pseudo-count. You are allowed to be Bayesian about this; the vanilla version above just starts blank.
What this buys you in practice
When we moved tier selection from config to a router like this, two things happened. Datacenter share of total traffic went from 12% to roughly 40% over three weeks — not because anything got cheaper, but because the router discovered how many origins genuinely did not care. And residential traffic dropped by a third while overall success rate stayed flat, because the router stopped sending premium exits to origins where datacenter was already succeeding.
The deeper point is organizational, not algorithmic. A tier map in a config file is an opinion someone had months ago, defended by no data, revisited by nobody. A bandit router is a running experiment whose opinion updates itself with every request. The diagram can still draw one proxy box — but inside that box, there is now a feedback loop instead of a decision somebody made once and forgot.
Top comments (0)