Technology Aug 25, 2026 · 7 min read

Your Proxy Is in Germany. The Rest of Your Client Isn't.

A reader on an earlier post described a scraper that was pulling Amazon listings through a rotating residential proxy and getting prices back in three different currencies inside one run. The thread treated it as a data-cleaning problem — normalise the currency, move on. I think it was the earliest...

DE
DEV Community
by RoamProxy
Your Proxy Is in Germany. The Rest of Your Client Isn't.

A reader on an earlier post described a scraper that was pulling Amazon listings through a rotating residential proxy and getting prices back in three different currencies inside one run. The thread treated it as a data-cleaning problem — normalise the currency, move on. I think it was the earliest visible symptom of something that gets scrapers blocked a lot more quietly than any IP list does: the exit IP said one country, and everything else about the client said another.

Geo-targeting a proxy is one line. country-de in the username, done. But the target isn't looking at one signal. It's looking at half a dozen, and if the other five still say "US developer laptop," the German IP doesn't make you look German. It makes you look like a US developer laptop using a German proxy, which is a much more specific — and much more suspicious — thing to look like.

The signals that have to agree

Here's what a target can read, without any JavaScript trickery, from a single request that arrives via a German residential exit.

Accept-Language. Python's requests sends nothing. httpx sends nothing. Playwright sends en-US,en;q=0.9 by default. A real German Chrome sends de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7. This header alone splits "German visitor" from "someone routing through Germany" on the first request, before any fingerprinting happens.

Timezone. If there's JS in the picture, Intl.DateTimeFormat().resolvedOptions().timeZone is one call. A headless browser inherits the host machine's zone. Your CI runner is in UTC; your laptop is in America/Los_Angeles; your exit is in Frankfurt. Two of those three will disagree with the IP, and the disagreement is a stronger signal than any single one of them.

Locale-derived formatting. navigator.language, navigator.languages, the date and number formats the page's own scripts produce when they run in your context. These all come from the browser's locale, not from the IP.

The currency and store you get served. This one's the tell that actually shows up in your data. Big e-commerce sites resolve your storefront from a combination of IP geolocation, Accept-Language, and any prior locale cookie. When those disagree, different code paths win on different page types — the search page keys on IP, the product page keys on the cookie, the cart keys on the account. That's how one run ends up with EUR, USD and GBP in the same output file. The mixed currencies aren't noise; they're a log of which signal each endpoint trusted.

DNS resolution location. Easy to forget. With an HTTP proxy, the proxy resolves the hostname and you're fine. With a SOCKS5 proxy configured as socks5:// rather than socks5h://, your machine resolves the hostname, so a CDN sees a DNS query from California and a TCP connection from Frankfurt. That's not a fingerprint most sites act on, but it does mean you get routed to the wrong edge, which is its own source of inconsistency (different A/B buckets, different cache, sometimes different content).

TLS session resumption. If you reuse a client across exits, the TLS session ticket from the previous connection can be presented on the next one. A session ticket issued to an IP in Ohio, resumed from Frankfurt, is a small oddity — but it's an oddity the server sees at the handshake, before your first byte of HTTP.

Why it's worse than a bad IP

An IP on a blocklist fails loudly. You get a 403, you notice, you deal with it.

Inconsistent geo fails softly. You get served a slightly different page. You land in a "we're not sure where you are" bucket that gets more challenges and fewer cached responses. Sticky sessions expire faster. A/B tests assign you to the control group. Nothing tells you this is happening; your success rate is just 8% lower than it should be, permanently, and the data has small irregularities you'll attribute to the target.

The other reason it's worse: rotating the IP doesn't fix it. It can't. The IP was the one signal that was right. If you read the previous post on block scopes, this is the fingerprint-scoped case — it fails identically from every exit because the exit isn't the problem.

Make the client agree with the exit

The fix is boring: derive every locale-ish setting from the exit's country, in one place, and never let anything else set them.

# geo_profile.py — one source of truth per exit country
import httpx

PROFILES = {
    "de": {"lang": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
           "tz": "Europe/Berlin", "locale": "de-DE"},
    "gb": {"lang": "en-GB,en;q=0.9",
           "tz": "Europe/London", "locale": "en-GB"},
    "us": {"lang": "en-US,en;q=0.9",
           "tz": "America/New_York", "locale": "en-US"},
    "jp": {"lang": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
           "tz": "Asia/Tokyo", "locale": "ja-JP"},
}

def client_for(country: str, proxy_url: str) -> httpx.Client:
    p = PROFILES[country]
    return httpx.Client(
        proxy=proxy_url,
        headers={"Accept-Language": p["lang"]},
        http2=True, timeout=20,
    )

def playwright_context_kwargs(country: str) -> dict:
    p = PROFILES[country]
    return {"locale": p["locale"], "timezone_id": p["tz"],
            "extra_http_headers": {"Accept-Language": p["lang"]}}

For Playwright that's browser.new_context(proxy=..., **playwright_context_kwargs("de"))locale and timezone_id are first-class context options and they fix navigator.language, Intl, and the header in one go. For plain HTTP clients it's just the header, plus using socks5h:// if you're on SOCKS.

Verify it before you trust it

Configuration you haven't checked is a guess. This is the audit I run when a new exit or a new client build goes into rotation — it asks a geo-echo endpoint what the server sees and compares that to what the client intends:

# geo_audit.py
import json, sys
import httpx

ECHO = "https://httpbin.org/anything"   # any endpoint that reflects headers + origin

def audit(client: httpx.Client, expect_country: str, expect_lang_prefix: str):
    r = client.get(ECHO)
    seen = r.json()
    origin = seen["origin"].split(",")[0]
    lang = seen["headers"].get("Accept-Language", "")
    geo = httpx.get(f"https://ipinfo.io/{origin}/json", timeout=10).json()
    problems = []
    if geo.get("country", "").lower() != expect_country:
        problems.append(f"exit country {geo.get('country')} != {expect_country}")
    if not lang.lower().startswith(expect_lang_prefix):
        problems.append(f"Accept-Language {lang!r} does not start with {expect_lang_prefix!r}")
    return origin, geo.get("country"), lang, problems

if __name__ == "__main__":
    from geo_profile import client_for
    country, proxy = sys.argv[1], sys.argv[2]
    with client_for(country, proxy) as c:
        origin, cc, lang, problems = audit(c, country, country if country != "gb" else "en-gb")
    print(json.dumps({"exit": origin, "country": cc, "lang": lang,
                      "ok": not problems, "problems": problems}, indent=2))

Run it once per country you target and once per client build. If ok is false, the request never reaches the real target — a client that disagrees with its own exit is not allowed into the pool. The audit takes about two seconds and it has caught, in order: a CI image that reset TZ=UTC after the context was created, a socks5:// that should have been socks5h://, and a "German" profile that was still sending en-US because the header was set on the session and then overwritten per-request by a helper someone added later.

The short version

An IP is one vote. Accept-Language, timezone, locale, DNS origin and TLS state are five more, and a target that geolocates at all is counting them. Pick the exit country, derive everything else from it, and assert the agreement before the first real request. Mixed currencies in your output aren't a cleaning job — they're the target telling you which of your signals it didn't believe.

We publish code examples and testing notes for developers who scrape and automate at RoamProxy. More runnable examples: github.com/roamproxy/proxy-examples.

DE
Source

This article was originally published by DEV Community and written by RoamProxy.

Read original article on DEV Community
Back to Discover

Reading List