Startseite
Standorte
Blog
Produkte
Wohn-Proxys Unbegrenzte Wohn-Proxys Statische Wohn-Proxies Statische Rechenzentrum-Proxys Statische Shared-Proxys
Ressourcen
Preise Standorte Blog Hilfecenter FAQs
Registrieren

Building a Regional Request Router With Residential Proxies in Python

Building a Regional Request Router With Residential Proxies in Python

Most proxy tutorials give you a proxies dict and call it done. That works for one-off tests with a single hard-coded country, but it breaks the moment you need US exit IPs for one task and German IPs for another—all from the same script, without importing a vendor SDK.

I've tested this router against two provider accounts: one using the user-country-US dash format and one that required user;country=US. The silent failure that caught me first was using the wrong format—the proxy accepted the request without a 407, but routed it through a random country instead of the one I specified. There's no warning, no error, just wrong data. The format probe below is the fix for that.

By the end of this guide you'll have:

  • A RegionalProxyRouter class that accepts region="DE" and builds the right credential string automatically

  • A format probe to confirm which username syntax your provider uses before any real traffic

  • Sticky session support for multi-step flows where a mid-stream country change would invalidate results

  • Concurrent multi-region fetching with proper thread isolation

  • Exit IP verification against ip-api.com before production requests go out

  • Retry and region fallback that correctly distinguishes auth errors from pool exhaustion

Prerequisites

You need Python 3.8 or newer and the requests library:

pip install requests

Collect four values from your proxy provider's dashboard:

  • Gateway hostname (e.g., gateway.example.com)

  • Port (e.g., 10000)

  • Username

  • Password

The one hard requirement: your provider must support region targeting through native proxy credentials—country selection encoded in the username—rather than a proprietary SDK API call. Most major residential proxy networks do this. If you're evaluating options, Proxy001 covers this model: 200+ countries with city- and carrier-level targeting in standard credentials, a pool of 100M+ addresses, and free test IPs to validate your setup before committing to a plan. The probe and verification functions in this guide work as a built-in acceptance test.

How Does Geo-Targeted Proxy Authentication Work?

Country targeting is encoded in the proxy username, not in a separate API call. Bright Data's proxy configuration docs show optional username parameters like -country-us, -city-sanfrancisco, and -session-mystring12345 appended directly to the base username. ProxyRack's geo-targeting docs document both the dash format (username-country-US) and the semicolon format (username;country=US) as valid patterns depending on account type.

A typical proxy URL looks like this:

http://myuser-country-US:mypassword@gateway.example.com:10000

Each field has a job:

  • http:// is the scheme used to connect to the proxy (even for HTTPS destination URLs—the proxy is reached over HTTP, and a CONNECT tunnel handles the TLS from there)

  • myuser-country-US is the base username with the targeting parameter appended

  • mypassword is your proxy password, which needs URL-encoding if it contains special characters

  • gateway.example.com:10000 is the provider's endpoint

Country codes follow ISO 3166-1 alpha-2: US, DE, JP, GB, and so on.

The problem with documentation is that provider docs don't always tell you which format applies to your account type. Using the wrong format doesn't throw a 407—it silently exits through a random country. Run this probe before writing any routing logic:

import requests
from urllib.parse import quote


def probe_username_mode(
    host: str,
    port: int,
    username: str,
    password: str,
    target_country: str = "US",
) -> str:
    """
    Tests both common username formats against ip-api.com and returns
    the one that correctly routes to target_country.

    Run this once when setting up a new provider account. Takes under
    five seconds and saves a lot of silent misdirection later.
    """
    check_url = "http://ip-api.com/json/?fields=status,countryCode,query"
    safe_pass = quote(password, safe="")

    for mode in ("dash_country", "semicolon_country"):
        if mode == "dash_country":
            geo_user = f"{username}-country-{target_country.upper()}"
        else:
            geo_user = f"{username};country={target_country.upper()}"

        proxy_url = (
            f"http://{quote(geo_user, safe=';=-_')}:{safe_pass}@{host}:{port}"
        )
        proxies = {"http": proxy_url, "https": proxy_url}

        try:
            resp = requests.get(check_url, proxies=proxies, timeout=15)
            data = resp.json()
            actual = data.get("countryCode", "")
            if data.get("status") == "success" and actual == target_country.upper():
                print(f"✓ '{mode}' works — exit IP {data['query']} confirmed in {actual}")
                return mode
            else:
                print(
                    f"✗ '{mode}' connected but exit country was {actual!r}, "
                    f"expected {target_country.upper()!r}"
                )
        except Exception as exc:
            print(f"✗ '{mode}' failed: {exc}")

    raise RuntimeError(
        "Neither username format routed to the expected country. "
        "Verify host, port, and credentials first, then retry."
    )

Run it before anything else:

mode = probe_username_mode("gateway.example.com", 10000, "myuser", "mypassword")
# ✓ 'dash_country' works — exit IP 104.28.x.x confirmed in US
# Use the returned mode string in ProxyConfig below.

Building the RegionalProxyRouter Class

With the format confirmed, the router needs three things: build the right credential string for any region, bind a requests.Session to that region, and expose request methods where region is a first-class parameter.

from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional
from urllib.parse import quote

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


class RegionUnavailableError(Exception):
    """Raised when all retry attempts across a region and its fallbacks are exhausted."""


@dataclass
class ProxyConfig:
    host: str
    port: int
    username: str
    password: str
    username_mode: str = "dash_country"  # "dash_country" or "semicolon_country"
    default_timeout: int = 20


class RegionalProxyRouter:
    def __init__(self, config: ProxyConfig) -> None:
        self.config = config
        # URL-encode once at construction time; password doesn't change per request.
        self._safe_password = quote(config.password, safe="")

    def _build_auth_username(
        self,
        region: str,
        session_id: Optional[str] = None,
    ) -> str:
        region = region.upper()
        mode = self.config.username_mode

        if mode == "dash_country":
            user = f"{self.config.username}-country-{region}"
        elif mode == "semicolon_country":
            user = f"{self.config.username};country={region}"
        else:
            raise ValueError(
                f"Unknown username_mode '{mode}'. "
                "Use 'dash_country' or 'semicolon_country'."
            )

        if session_id:
            # Appends a session token to ask the network to pin the same peer IP.
            # Provider support varies—check your provider's docs for the exact parameter name.
            user = f"{user}-session-{session_id}"

        return user

    def _build_proxy_url(
        self,
        region: str,
        session_id: Optional[str] = None,
    ) -> str:
        user = self._build_auth_username(region=region, session_id=session_id)
        safe_user = quote(user, safe=";=-_")
        return (
            f"http://{safe_user}:{self._safe_password}"
            f"@{self.config.host}:{self.config.port}"
        )

    def get_proxies(
        self,
        region: str,
        session_id: Optional[str] = None,
    ) -> Dict[str, str]:
        proxy_url = self._build_proxy_url(region=region, session_id=session_id)
        # Both keys use http:// because requests connects TO the proxy via HTTP
        # and opens a CONNECT tunnel for HTTPS destinations.
        return {"http": proxy_url, "https": proxy_url}

    def _build_retry_adapter(self) -> HTTPAdapter:
        # Handles HTTP-level transient errors (429, 5xx) from the destination server.
        # This is separate from the region-level retry in get_with_fallback(), which
        # handles connection-level failures like timeouts and proxy pool exhaustion.
        retry = Retry(
            total=2,
            backoff_factor=0.5,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods={"GET", "HEAD", "OPTIONS"},
            respect_retry_after_header=True,
        )
        return HTTPAdapter(max_retries=retry)

    def get_session(
        self,
        region: str,
        session_id: Optional[str] = None,
        headers: Optional[Dict[str, str]] = None,
    ) -> requests.Session:
        s = requests.Session()
        # Disables environment-variable proxy overrides (HTTP_PROXY, HTTPS_PROXY,
        # REQUESTS_CA_BUNDLE). Without this, system proxy config—common in CI/CD
        # pipelines or corporate networks—can silently override the regional
        # routing you just built. Note: this also disables .netrc authentication
        # and CURL_CA_BUNDLE, so set verify= explicitly if you need custom CA certs.
        s.trust_env = False
        s.proxies = self.get_proxies(region=region, session_id=session_id)
        s.mount("http://", self._build_retry_adapter())
        s.mount("https://", self._build_retry_adapter())
        if headers:
            s.headers.update(headers)
        return s

    def request(
        self,
        method: str,
        url: str,
        region: str,
        session_id: Optional[str] = None,
        timeout: Optional[int] = None,
        **kwargs,
    ) -> requests.Response:
        with self.get_session(region=region, session_id=session_id) as session:
            response = session.request(
                method=method.upper(),
                url=url,
                timeout=timeout or self.config.default_timeout,
                **kwargs,
            )
            response.raise_for_status()
            return response

    def get(self, url: str, region: str, **kwargs) -> requests.Response:
        return self.request("GET", url=url, region=region, **kwargs)

    def post(self, url: str, region: str, **kwargs) -> requests.Response:
        return self.request("POST", url=url, region=region, **kwargs)

    def get_with_fallback(
        self,
        url: str,
        region: str,
        fallback_regions: Optional[Iterable[str]] = None,
        max_attempts_per_region: int = 2,
        backoff_seconds: float = 1.0,
        **kwargs,
    ) -> requests.Response:
        """
        Attempts the request in `region` with exponential backoff on transient
        failures. If the region's pool stays unavailable, tries each region in
        `fallback_regions` in order. Raises RegionUnavailableError when all
        options are exhausted.

        ProxyError (407 / CONNECT tunnel failure) raises immediately—this
        indicates a credential or format problem, not a pool problem, so
        retrying different regions won't help and would produce a misleading
        RegionUnavailableError.

        Two-layer retry note: each self.get() call already carries urllib3-level
        retries for HTTP 429/5xx responses via the session adapter. This method
        adds region-level fallback on top, for connection-level failures (timeouts,
        pool exhaustion). The two layers target different failure modes and don't
        interfere, but factor both in when tuning retry counts.
        """
        regions = [region] + list(fallback_regions or [])
        errors: List[tuple] = []

        for current_region in regions:
            for attempt in range(1, max_attempts_per_region + 1):
                try:
                    return self.get(url=url, region=current_region, **kwargs)
                except requests.exceptions.ProxyError as exc:
                    # 407 or CONNECT tunnel failure: credential or format problem.
                    # Raise immediately—switching regions won't fix bad credentials.
                    raise RegionUnavailableError(
                        f"Proxy auth/tunnel error on '{current_region}': {exc}. "
                        f"Check username_mode and credentials."
                    ) from exc
                except requests.RequestException as exc:
                    errors.append((current_region, attempt, repr(exc)))
                    if attempt < max_attempts_per_region:
                        time.sleep(backoff_seconds * attempt)

        raise RegionUnavailableError(
            f"All regions exhausted after {max_attempts_per_region} attempts each. "
            f"Errors: {errors}"
        )

Instantiate the router using the username_mode returned by probe_username_mode:

config = ProxyConfig(
    host="gateway.example.com",
    port=10000,
    username="myuser",
    password="mypassword",
    username_mode="dash_country",  # value returned by probe_username_mode()
)

router = RegionalProxyRouter(config)

us_resp = router.get("https://httpbin.org/ip", region="US")
de_resp = router.get("https://httpbin.org/ip", region="DE")
print(us_resp.text, de_resp.text)

If your application routes by target domain, keep that logic outside the router so the class stays focused:

from urllib.parse import urlparse

REGION_RULES: Dict[str, str] = {
    "example.de": "DE",
    "example.co.jp": "JP",
    "example.co.uk": "GB",
    "example.com": "US",
}

def pick_region(url: str, default: str = "US") -> str:
    hostname = urlparse(url).hostname or ""
    return REGION_RULES.get(hostname, default)

target = "https://example.de/product/123"
resp = router.get(target, region=pick_region(target))

The router builds credentials; your application decides which region to request.

How Do You Keep the Same Country IP Across Multiple Requests?

Use a sticky session when your workflow requires continuity across multiple requests. Bright Data's configuration docs show how appending a session token like -session-mystring12345 to the username asks the network to keep routing through the same peer IP for that session.

The get_session() method already handles this through the session_id parameter:

session = router.get_session(region="US", session_id="checkout-flow-001")

try:
    login = session.post(
        "https://example.com/login",
        json={"user": "alice", "pass": "secret"},
        timeout=20,
    )
    profile = session.get("https://example.com/profile", timeout=20)
    cart = session.get("https://example.com/api/cart", timeout=20)
    print(login.status_code, profile.status_code, cart.status_code)
finally:
    session.close()

When to use which approach:

  • Multi-step flows (login → browse → submit, checkout validation, account-level ad QA): use get_session() with a session_id. A country switch mid-flow triggers re-authentication on most sites and corrupts the session state you're testing.

  • Stateless single-request tasks (public price checks, open-access page fetches): use router.get() directly. No session overhead, and a fresh exit IP per request is usually fine.

One scenario get_session() doesn't cover: if you need the same IP address to persist across multiple script runs—across days, not just one session—that's a use case for static ISP proxies rather than rotating residential proxies. The two have different continuity trade-offs: residential proxies rotate the underlying peer between sessions by design, while ISP proxies assign a fixed IP to your account. For everything within a single script run, the sticky session approach here is the right tool.

Routing Multiple Regions Concurrently

ThreadPoolExecutor is the right call for concurrent multi-region requests with requests. Give each worker its own session—requests.Session is not thread-safe to share across threads.

from concurrent.futures import ThreadPoolExecutor, as_completed

REGIONS = ["US", "DE", "JP"]
TEST_URL = "http://ip-api.com/json/?fields=status,country,countryCode,query"


def fetch_region(region: str) -> dict:
    with router.get_session(
        region=region, session_id=f"batch-{region.lower()}"
    ) as session:
        resp = session.get(TEST_URL, timeout=20)
        resp.raise_for_status()
        return {"requested": region, "result": resp.json()}


results = []
with ThreadPoolExecutor(max_workers=len(REGIONS)) as executor:
    futures = [executor.submit(fetch_region, region) for region in REGIONS]
    for future in as_completed(futures):
        try:
            results.append(future.result())
        except Exception as exc:
            print(f"Worker failed: {exc}")

for item in results:
    r = item["result"]
    print(f"[{item['requested']}] → exited in {r.get('countryCode')!r}, IP: {r.get('query')}")

The RegionalProxyRouter instance is safe to share across workers—it's stateless and only reads from config. Sessions are created inside each worker and never shared.

Verifying Your Exit IP Is in the Right Country

Treat region selection as unverified until you confirm it with a real request. ip-api.com's JSON API returns countryCode, country, status, and the queried IP address in one HTTP call—no authentication needed on the free tier.

import time


def verify_exit_region(
    router: RegionalProxyRouter,
    expected_country_code: str,
    timeout: int = 20,
) -> dict:
    """
    Confirms the exit IP is in the expected country.

    Uses ip-api.com's free HTTP JSON endpoint (HTTP only on the free tier—
    HTTPS requires a paid key at pro.ip-api.com).

    ip-api.com's free tier enforces a per-minute rate limit (see ip-api.com/docs
    for the current cap). If you hit it, the API returns HTTP 429 with X-Rl
    (requests remaining) and X-Ttl (seconds until reset) headers in the response.
    A short sleep between calls—1-2 seconds is typically sufficient—keeps most
    batch verification runs well within the limit. For high-volume verification,
    use a commercial key at http://pro.ip-api.com/json/ to avoid the cap entirely.
    """
    url = "http://ip-api.com/json/?fields=status,message,country,countryCode,query"
    resp = router.get(url, region=expected_country_code, timeout=timeout)
    data = resp.json()

    if data.get("status") != "success":
        raise RuntimeError(
            f"ip-api.com returned a failure status. Full response: {data}"
        )

    actual = data.get("countryCode", "")
    expected = expected_country_code.upper()

    if actual != expected:
        raise AssertionError(
            f"Exit country mismatch: requested {expected!r}, "
            f"got {actual!r}. Exit IP was {data.get('query')}."
        )

    return data

Running verify_exit_region(router, "US") against a US residential proxy on April 14, 2026 returned the following (exit IP replaced with 203.0.113.0/24, the RFC 5737 documentation range):

{
  "status": "success",
  "country": "United States",
  "countryCode": "US",
  "query": "203.0.113.47"
}

Run this for every country in your routing table when you first configure a provider, and again after any credential change. The function raises a clear AssertionError on mismatch—straightforward to use as a startup assertion before your script begins real work:

for country in ["US", "DE", "JP"]:
    result = verify_exit_region(router, country)
    print(f"✓ {country} — exit IP {result['query']}")
    time.sleep(1.5)  # short pause to stay within ip-api.com's free-tier rate limit

What Happens When a Region's IPs Are Unavailable?

get_with_fallback() handles this, with one key distinction baked into the code: a ProxyError (407 or CONNECT tunnel failure) raises immediately because it's a credential or format problem—switching to a different region won't help and would produce a misleading error. A connection timeout, on the other hand, usually means the exit pool for that country is momentarily exhausted; retrying or falling back to a nearby market may succeed.

try:
    resp = router.get_with_fallback(
        url="https://httpbin.org/ip",
        region="DE",
        fallback_regions=["NL", "FR"],
        max_attempts_per_region=2,
        timeout=20,
    )
    print(resp.text)
except RegionUnavailableError as exc:
    print(f"All regions failed: {exc}")

Make the fallback decision explicit for your use case: if the exact country is a business requirement (German pricing data, geo-restricted content QA), fail hard and alert. If the country is preferred but a neighboring market still serves the purpose, fall through. Silent fallbacks that quietly substitute the wrong country are worse than loud failures.

Troubleshooting

407 Proxy Authentication Required

This is a credential or format problem, not a network problem. Check:

  • Whether username_mode matches your account type—run probe_username_mode() if unsure

  • The password for special characters; the router URL-encodes it, but if you're constructing URLs manually, verify the encoding

  • Whether your provider requires additional username parameters in a fixed order

A 407 raised inside get_with_fallback() surfaces as a RegionUnavailableError with "Proxy auth/tunnel error" in the message. That's the early-raise behavior described in the docstring—it means the problem is in your credentials, not in the region pool.

Request succeeds but exit IP is in the wrong country

This is the dangerous silent failure. The proxy accepted the request but ignored or misread the country parameter. Most common causes:

  • Wrong username_mode—run probe_username_mode() to confirm

  • Provider account not provisioned for that region

  • Using router.get() (stateless, new session each call) when you needed get_session() with a session_id for a flow that requires IP continuity

Fix: run verify_exit_region() for each country before doing any real work, and again if exit countries start drifting unexpectedly.

HTTPS requests fail, HTTP requests work

Confirm that both http and https keys are present in the proxies dict—the router's get_proxies() method sets both, but this is a common mistake when constructing proxy URLs manually.

If both keys are set and HTTPS still fails: some providers intercept TLS at the proxy level and require you to trust their CA certificate. Check your provider's docs for TLS configuration notes. verify=False will confirm that's the issue during debugging (don't leave it in production). If your provider's gateway requires HTTP/1.1 enforcement, that can be configured at the urllib3 adapter level—check the proxy_http_version setting in your provider's integration docs—but most residential proxy gateways handle version negotiation automatically.

Setting trust_env = False in the session also disables REQUESTS_CA_BUNDLE and CURL_CA_BUNDLE. If you need a custom CA bundle alongside regional routing, pass verify="/path/to/ca-bundle.crt" explicitly in your request calls rather than relying on the environment variable.

Concurrent requests return mismatched countries

A session is being shared across workers. Move router.get_session(region) inside the worker function so each thread creates and owns its own session. The router instance can be shared; sessions cannot.

A Note on Responsible Use

Regional request routing is a legitimate tool for ad verification, localization QA, uptime monitoring, and collecting publicly accessible data across markets. Before pointing the router at any target, check that site's robots.txt and terms of service, keep your request rate reasonable, and don't use residential proxies to access content you don't have authorization to access.


If you're moving this router into production, the residential proxy account behind it matters as much as the code. Proxy001 provides residential proxies across 200+ countries with city- and carrier-level targeting built directly into standard proxy credentials—no SDK required, which is exactly what this router was designed for. The pool covers 100M+ addresses. If your use case calls for residential proxies with unlimited bandwidth or high-volume throughput, check proxy001.com directly for current plan options.

Start with free test IPs at proxy001.com: run probe_username_mode() to confirm the credential format, then verify_exit_region() for each country in your routing table. Once all country codes come back correct, the router handles the rest.

Starten Sie Ihren sicheren und stabilen
globalen Proxy-Dienst
Beginnen Sie in nur wenigen Minuten und entfesseln Sie das volle Potenzial von Proxys.
Erste Schritte