Using Proxies with aiohttp: The Four Things That Fail Quietly
aiohttp handles proxies differently from requests, and the differences fail silently: SOCKS5 connectors, timeouts, and pooling that breaks rotation.
Loading page content.
A 407 means the proxy refused your credentials, and there are exactly six reasons it does that. Here is how to tell them apart in under a minute.
Dana Whitfield
· updated 23 Aug 2026
HTTP 407 Proxy Authentication Required is the proxy talking, not the target site. Your request never left the gateway. That is good news — it narrows the search enormously — but the error text is identical across six unrelated causes, which is why it wastes so much time.
Work through these in order. The first two account for most reports.
The commonest cause is a client that parses the URL but drops the userinfo. Confirm with curl, which handles it correctly and gives you a known-good baseline:
curl -v -x http://user-country-us:[email protected]:8080 \
https://api.fleetproxy.com/v1/ipIf curl succeeds and your code does not, the bug is in your code, not the account. The usual suspects:
fetch ignores HTTP_PROXY entirely and has no proxy option. You need a dispatcher.session.proxies, but an explicit proxies= argument on a call overrides it, and a trust_env=False client silently ignores environment variables.Proxy-Authorization only after a challenge, and drop it on redirect.For Node, use undici's ProxyAgent:
import { ProxyAgent, request } from "undici";
const agent = new ProxyAgent({
uri: "http://gate.fleetproxy.com:8080",
token: "Basic " + Buffer.from("user-country-us:pass").toString("base64"),
});
const { statusCode, body } = await request("https://api.fleetproxy.com/v1/ip", {
dispatcher: agent,
});
console.error(statusCode, await body.text());If a password contains @, :, /, # or ?, embedding it raw in a proxy URL produces a string that parses into the wrong fields. A password of p@ss:1 turns the host into ss:1 and the error you get is 407, not a parse failure.
Percent-encode it, or keep credentials out of the URL entirely:
from urllib.parse import quote
user = quote("user-country-us", safe="")
password = quote("p@ss:1", safe="")
proxy = f"http://{user}:{password}@gate.fleetproxy.com:8080"Rotating the password to an alphanumeric string is the more durable fix. Every layer between your config file and the socket is a place where encoding can be lost.
FleetProxy encodes targeting in the username: user-country-us, user-country-gb-city-manchester, user-country-us-asn-7922. If a segment names a pool that does not exist — a misspelled country, a city with no inventory, an ASN that has no presence in the requested country — the gateway rejects the credential rather than quietly serving a different location.
That rejection is a 407 with a reason header:
curl -sI -x http://user-country-us-city-atlantis:[email protected]:8080 \
https://api.fleetproxy.com/v1/ip | grep -i x-fleet-reason
# x-fleet-reason: no-matching-poolCountries are ISO 3166-1 alpha-2, lowercase. Cities are lowercase with hyphens for spaces: new-york, sao-paulo. The locations page lists what is live.
If the credential has an IP whitelist attached, password authentication is still required and the source address must be on the list. A dynamic office IP, a new CI runner, or a container that egresses through a different NAT will all produce 407.
Check the whitelist on the credential in the dashboard, and remember that whitelists are per-credential, not per-account. Adding your address to one credential does not add it to the others.
An exhausted or expired plan stops authenticating. The reason header distinguishes it:
x-fleet-reason | Meaning |
|---|---|
bad-credentials | Username or password is wrong |
no-matching-pool | Targeting segment resolves to nothing |
ip-not-whitelisted | Source address not on the credential's list |
quota-exhausted | Plan has no bandwidth remaining |
plan-inactive | Plan expired, suspended, or not yet provisioned |
Bandwidth on FleetProxy plans does not expire, but a plan can still be suspended for a failed payment, and a suspended plan authenticates exactly like a wrong password unless you read the header.
Port 8080 is HTTP and HTTPS via CONNECT. Port 1080 is SOCKS5. SOCKS5 authentication is a different handshake, and pointing an HTTP client at 1080 produces a confusing failure that some libraries surface as 407. Match the scheme to the port:
# HTTP/HTTPS
curl -x http://user-country-us:[email protected]:8080 https://example.com
# SOCKS5, with remote DNS resolution
curl -x socks5h://user-country-us:[email protected]:1080 https://example.comUse socks5h, not socks5. The h resolves DNS at the proxy; without it your resolver leaks the hostname locally and, on geo-targeted work, resolves to the wrong CDN edge.
x-fleet-reason on the failing response.If all six pass and you are still seeing 407, send support the failing username (never the password) and the timestamp with a timezone. Gateway auth decisions are logged and can be looked up directly.
aiohttp handles proxies differently from requests, and the differences fail silently: SOCKS5 connectors, timeouts, and pooling that breaks rotation.
A mobile exit is shared with thousands of real subscribers, which is what makes it hard to block. How dedicated LTE ports differ from pooled bandwidth.
Working proxy configuration for the three clients most Python scrapers use, including HTTPS via CONNECT, SOCKS5, retries, and the environment-variable behaviour that silently breaks CI.
Every snippet in this article points at the production gateway. Create an account, take the 50 MB residential trial, and swap in your credentials.
No card required for the trial. Cancel or downgrade at any time.