DEV Community

Greta
Greta

Posted on

Anatomy of a Proxy URL: Every Segment of That Config String Means Something

A proxy URL is not an opaque string you paste into config and forget. It is an interface — a compact, overloaded piece of text where every segment carries behavioral meaning, and getting a segment wrong changes how your traffic exits the network, not just whether it connects. Most "my proxy doesn't work" incidents I debug from juniors are not authentication failures or dead IPs. They are misread segments of the config string: a password that swallowed the hostname, a socks5:// handed to a library that only speaks http://, or a sticky session silently rotating because a ?session= parameter was dropped when the URL was assembled by hand. Learn to read the string and you eliminate a whole category of outages.

This article takes a single proxy URL apart, segment by segment, and then rebuilds it in Python so you can parse, validate, and construct these strings programmatically instead of praying that copy-paste worked.

The anatomy

Here is a residential gateway URL we will dissect:

http://scraper:S3cr3t%40Key@gw.res.example.com:22225/session?country=US&city=Chicago&session=7f3a9c&sessTime=30
Enter fullscreen mode Exit fullscreen mode

Five regions matter: the scheme, the credentials, the host:port, the path, and the query parameters. The credentials and the query are where beginners lose the most hours.

Scheme: http, https, and socks5 are not interchangeable

The scheme tells the client what handshake to perform before a single byte of your real request leaves the machine.

  • http:// proxies are plain HTTP proxies. For an HTTPS destination, the client sends a CONNECT target.example.com:443 request to the proxy, the proxy opens a TCP tunnel, and your TLS session runs end to end through that pipe. The proxy never sees your payloads. This is the default for scraping because it is the most widely supported.
  • https:// proxies do the same thing, except the hop between you and the proxy is itself TLS. You gain protection from anyone sniffing the path to the proxy, and you usually need to trust the proxy's certificate (or set ssl_verify=False, which you should treat as a red flag outside of throwaway testing).
  • socks5:// operates at layer 5. It does not speak HTTP or CONNECT in the same way — it negotiates a SOCKS session that can relay arbitrary TCP (and sometimes UDP). socks5h:// is the variant that asks the proxy to resolve your hostname instead of resolving it locally, which matters when the target only exists from the proxy's network location or when you want to avoid local DNS leaking your intent.

The practical consequence: a scheme is a promise about capability. If your client cannot speak SOCKS, a socks5:// string is not "the same thing over a different port" — it is unusable, and the failure mode is often a confusing ImportError or a silently ignored proxy rather than a clean error.

Credentials: username:password and the percent-encoding trap

Between the @ and the scheme lives username:password. The separator that trips people is that the same characters used inside a password — @, :, / — are the delimiters the parser uses to find those fields. Passwords are random bytes dressed up as text, so they absolutely will contain @ and :.

Take S3cr3t@Key as a password. Written raw:

http://scraper:S3cr3t@Key@gw.res.example.com:22225
Enter fullscreen mode Exit fullscreen mode

The authority is delimited by the @ before the host. Depending on how the client splits, it may treat S3cr3t as the end of the password and Key as the start of your hostname — or it may split on the last @ and produce a password of S3cr3t@Key with host gw.res.example.com. That ambiguity is the bug: the same string can parse two ways across two libraries. The fix is not to pick a "better" parser; the fix is to encode the password so the delimiters disappear:

http://scraper:S3cr3t%40Key@gw.res.example.com:22225
Enter fullscreen mode Exit fullscreen mode

%40 is @, %3A is :. Once encoded, there is exactly one unescaped @ in the string and every parser agrees on where credentials end and host begins. We will use urllib.parse.quote for this later.

host:port and the path

host:port needs no philosophy, except that residential and datacenter gateways lean on unusual ports (22225, 8000, 9000, high ephemeral ranges) precisely because they multiplex many targets through one listener. Always include the port explicitly; a missing port is the second-most common beginner error after the unescaped @, and urlsplit will hand you port=None and let the request quietly default to 80 or 443.

Some gateways also put a token in the path (note /session above). Path semantics are provider-specific, so never assume it is decoration.

Query parameters: rotation, stickiness, and geo-pinning

This is where residential providers encode behavior rather than just location:

  • session=7f3a9c is the rotation switch. The same session token for the lifetime of the session pins you to one exit IP. Rotate the token (or omit it, depending on provider) and every request may land on a different exit.
  • sessTime=30 sets how long a session sticks, in minutes. A sticky IP that expires mid-login looks like the site "banned" you when really your session rotated underneath you.
  • country=US&city=Chicago pins geography. Pin a country when you need locale-correct results; pin a city when a /24 or ASN-level reputation block is the thing you are routing around.

Read as an interface, ?session=<fixed>&sessTime=30 means "behave like a single stable user for half an hour," while a fresh random session per request means "behave like many distinct users." That distinction is the entire ballgame for anti-bot systems.

Parsing a proxy URL in Python

Stop treating the string as a string. Parse it into a typed object so you can validate, inspect, and re-serialize it. This uses urllib.parse only — no dependencies.

from dataclasses import dataclass, field
from typing import Dict, List, Optional
from urllib.parse import urlsplit, unquote, parse_qs, quote

ALLOWED_SCHEMES = {"http", "https", "socks5", "socks5h"}


@dataclass
class ProxyEndpoint:
    scheme: str
    host: str
    port: int
    username: Optional[str] = None
    password: Optional[str] = None
    path: str = ""
    session: Optional[str] = None
    country: Optional[str] = None
    city: Optional[str] = None
    sess_time: Optional[int] = None
    raw_query: Dict[str, List[str]] = field(default_factory=dict)

    @property
    def sticky(self) -> bool:
        return self.session is not None

    def to_requests(self) -> str:
        """Re-serialize as a URL safe for requests/httpx, credentials re-encoded."""
        auth = ""
        if self.username is not None:
            pw = quote(self.password or "", safe="")
            auth = f"{quote(self.username, safe='')}:{pw}@"
        q = ""
        if self.raw_query:
            items = {k: v[0] for k, v in self.raw_query.items()}
            from urllib.parse import urlencode
            q = "?" + urlencode(items)
        return f"{self.scheme}://{auth}{self.host}:{self.port}{self.path}{q}"


def parse_proxy_url(url: str) -> ProxyEndpoint:
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    if scheme not in ALLOWED_SCHEMES:
        raise ValueError(
            f"unsupported scheme {scheme!r}; expected one of {sorted(ALLOWED_SCHEMES)}"
        )
    if not parts.hostname:
        raise ValueError("proxy URL has no host — check for an unencoded '@' in the password")
    try:
        port = parts.port
    except ValueError as exc:
        raise ValueError(f"invalid port in {url!r}") from exc
    if port is None:
        raise ValueError("proxy URL must include an explicit port (host:port)")

    if parts.password is not None and parts.username is None:
        raise ValueError("password supplied without a username")

    username = unquote(parts.username) if parts.username is not None else None
    password = unquote(parts.password) if parts.password is not None else None

    query = parse_qs(parts.query)
    sess_time_raw = query.get("sessTime", [None])[0]
    try:
        sess_time = int(sess_time_raw) if sess_time_raw is not None else None
    except ValueError as exc:
        raise ValueError(f"sessTime must be an integer, got {sess_time_raw!r}") from exc

    return ProxyEndpoint(
        scheme=scheme,
        host=parts.hostname,
        port=port,
        username=username,
        password=password,
        path=parts.path,
        session=query.get("session", [None])[0],
        country=query.get("country", [None])[0],
        city=query.get("city", [None])[0],
        sess_time=sess_time,
        raw_query=query,
    )
Enter fullscreen mode Exit fullscreen mode

Now feed it a plain authenticated datacenter proxy and a residential gateway URL with sticky-session params:

if __name__ == "__main__":
    dc = parse_proxy_url("http://bob:Pass%3Aword@198.51.100.7:8080")
    res = parse_proxy_url(
        "http://scraper:S3cr3t%40Key@gw.res.example.com:22225/session"
        "?country=US&city=Chicago&session=7f3a9c&sessTime=30"
    )

    print("datacenter:", dc.host, dc.port, "user=", dc.username, "pass=", dc.password)
    #  datacenter: 198.51.100.7 8080 user= bob pass= Pass:word
    #  -> the %3A decoded back to a literal ':' in the password.

    print("residential sticky?", res.sticky, "session=", res.session,
          "geo=", res.country, res.city, "min=", res.sess_time)
    #  residential sticky? True session= 7f3a9c geo= US Chicago min= 30

    print("round-trips:", parse_proxy_url(res.to_requests()) == res)
    #  round-trips: True
Enter fullscreen mode Exit fullscreen mode

parts.username and parts.password already URL-decode for us in modern Python, but I run unquote explicitly so the code behaves identically across versions and so the intent is visible. The to_requests() / re-parse round-trip is the cheap test that catches most malformed strings.

Constructing sticky vs rotating exits

Parsing is for the config you were handed. Building is for the config you generate per request. The rule is one line: same session token → same exit; fresh or omitted token → new exit.

import secrets

HOST = "gw.res.example.com"
PORT = 22225
USER = "scraper"
PASSWORD = "S3cr3t@Key"          # contains '@' on purpose — must be encoded


def build_proxy_url(
    *, session: str | None, country: str = "US",
    city: str | None = None, sess_time: int | None = 30,
) -> str:
    params = {"country": country}
    if city:
        params["city"] = city
    if session is not None:
        params["session"] = session
    if sess_time is not None:
        params["sessTime"] = sess_time

    from urllib.parse import urlencode
    query = "?" + urlencode(params)
    creds = f"{quote(USER, safe='')}:{quote(PASSWORD, safe='')}"
    return f"http://{creds}@{HOST}:{PORT}{query}"


# Three rotating URLs: a fresh random session id each time => fresh exit per request.
rotating = [build_proxy_url(session=secrets.token_hex(4)) for _ in range(3)]

# One pinned URL: a fixed session id + long sessTime => stable identity.
pinned = build_proxy_url(session="stable-7f3a9c", sess_time=120)

for u in rotating:
    print("ROTATE:", u)
print("PINNED:", pinned)
Enter fullscreen mode Exit fullscreen mode

quote(PASSWORD, safe="") turns S3cr3t@Key into S3cr3t%40Key, so the assembled URL has exactly one @ — the delimiter — and survives the trip through parse_proxy_url() above.

A preflight check you can trust

Before you wire proxies into a long crawl, prove the semantics: rotating URLs should exit from different IPs, and the pinned URL should keep returning the same one.

import requests

def exit_ip(proxy_url: str) -> str:
    r = requests.get(
        "https://ipinfo.io/json",
        proxies={"http": proxy_url, "https": proxy_url},
        timeout=15,
    )
    r.raise_for_status()
    return r.json()["ip"]


def preflight(rotating_urls: list[str], pinned_url: str) -> None:
    rotating_ips = [exit_ip(u) for u in rotating_urls]
    assert len(set(rotating_ips)) > 1, \
        f"rotating proxies collapsed to one exit: {rotating_ips}"

    pinned_ips = {exit_ip(pinned_url) for _ in range(3)}
    assert len(pinned_ips) == 1, \
        f"sticky session rotated: {pinned_ips}"

    print("rotating exits:", rotating_ips)
    print("pinned exit:   ", pinned_ips.pop())


if __name__ == "__main__":
    preflight(rotating, pinned)
Enter fullscreen mode Exit fullscreen mode

Expected output (illustrative — actual IPs depend on your provider's pool, this is not a log from a real run):

rotating exits: ['68.42.19.10', '174.21.88.203', '24.130.55.7']
pinned exit:    96.54.12.181
Enter fullscreen mode Exit fullscreen mode

The rotating list is expected to vary because each URL carries a different session token; the pinned value is expected to repeat because three separate calls all carry session=stable-7f3a9c. If you ever see the pinned set come back with more than one IP, the bug is upstream — you are rebuilding the URL with a fresh token somewhere and forgot — not in the provider.

The percent-encoding checklist

Run this on any new proxy config string before it touches production:

  • Re-parse it with urlsplit and confirm hostname is non-empty; if the host looks wrong, hunt for an unencoded @ or : in the password.
  • Confirm a port is present and is the number you expect — never rely on a default.
  • Confirm the scheme is one your target library actually supports (see below).
  • Percent-encode credentials with quote(value, safe="") and decode with unquote — never hand-type % sequences.
  • Confirm whether rotation is driven by a session query param, a username field, or path, and check sessTime against your session lifetime needs.
  • If you log or display these URLs, redact the password (creds.split(":")[1] is not a credential, it is an incident).
  • Round-trip test: parse(build(url)) == url; if it differs, the string was malformed to begin with.

Scheme support differs by library

This trips people because the failure is often silent.

  • requests speaks http:// and https:// proxies out of the box, but socks5:// requires the optional requests[socks] extra (PySocks). Without it you get a raised error about an unsupported proxy scheme, not a clean rejection at parse time.
  • aiohttp has native SOCKS5 support through aiohttp_socks/TCPConnector(proxy=...); it does not accept a bare socks5:// in the same proxies= dict shape that requests uses, so the plumbing differs from sync code.
  • httpx handles http:// and https:// proxies natively and supports socks5:// when installed with the httpx[socks] extra; it also distinguishes proxy-TLS from target-TLS, so an https:// proxy needs the proxy's certificate to be trusted.

None of these is wrong — they are different contracts on top of the same scheme token. Which reinforces the thesis: the scheme segment is a capability claim, and each library decides which claims it honors.

Closing thought

Treat every proxy URL as a small API. The scheme is the transport contract, the credentials are a delimiter-sensitive field that must be encoded, the host:port is a required non-default, and the query string is the rotation-and-geo policy knob. Parse it into an object, validate each segment, and preflight the behavior once before you trust it for a thousand requests — and most of those "doesn't work" tickets stop existing.

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)