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
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 aCONNECT target.example.com:443request 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 setssl_verify=False, which you should treat as a red flag outside of throwaway testing). -
socks5://operates at layer 5. It does not speak HTTP orCONNECTin 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
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
%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=7f3a9cis 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=30sets 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=Chicagopins 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,
)
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
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)
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)
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
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
urlsplitand confirmhostnameis 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 withunquote— never hand-type%sequences. - Confirm whether rotation is driven by a
sessionquery param, a username field, or path, and checksessTimeagainst 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.
-
requestsspeakshttp://andhttps://proxies out of the box, butsocks5://requires the optionalrequests[socks]extra (PySocks). Without it you get a raised error about an unsupported proxy scheme, not a clean rejection at parse time. -
aiohttphas native SOCKS5 support throughaiohttp_socks/TCPConnector(proxy=...); it does not accept a baresocks5://in the sameproxies=dict shape thatrequestsuses, so the plumbing differs from sync code. -
httpxhandleshttp://andhttps://proxies natively and supportssocks5://when installed with thehttpx[socks]extra; it also distinguishes proxy-TLS from target-TLS, so anhttps://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)