A 502 Bad Gateway, 503 Service Unavailable or 504 Gateway Timeout that arrives while the client is opening the proxy tunnel comes from the proxy: the tunnel never opened and the website never saw your request. A 502 that arrives after the tunnel opened comes from the website, or from something in front of it. Same number, different owner, different fix. Every error string and number below comes from a loopback lab run on 5 November 2026, linked at the end.
Which side sent the 502
For an https:// URL the client first asks the proxy for a tunnel with CONNECT host:443 (RFC 9110). If the proxy answers anything but 2xx, there is no tunnel and most clients raise an exception. If the tunnel opens and the website answers 502, you get an ordinary response.
| Client | Proxy refused the tunnel | Website answered 502 |
|---|---|---|
| curl 8.5.0 | exit 56, curl: (56) CONNECT tunnel failed, response 502 | exit 0, %{http_code} is 502, %{http_connect} is 200 |
| curl 8.18.0 | exit 7, curl: (7) CONNECT tunnel failed, response 502 | exit 0, %{http_code} is 502, %{http_connect} is 200 |
| Requests 2.34 | requests.exceptions.ProxyError with Tunnel connection failed: 502 Bad Gateway | Response with status_code == 502 |
| HTTPX 0.28 | httpx.ProxyError: 502 Bad Gateway | Response with status_code == 502 |
| aiohttp 3.14 | ClientHttpProxyError: 502, message='Bad Gateway' | response with status == 502 |
| Playwright 1.63 | page.goto: net::ERR_TUNNEL_CONNECTION_FAILED | page.goto resolves, status() is 502 |
| Node.js 24 fetch | TypeError: fetch failed, and err.cause.cause.message is Proxy response (502) !== 200 when HTTP Tunneling | response.status is 502 |
503 and 504 follow the same pattern with their own number, for example Tunnel connection failed: 503 Service Unavailable or curl: (7) CONNECT tunnel failed, response 504. Chromium reported net::ERR_TUNNEL_CONNECTION_FAILED for all three, without the status; the ERR_TUNNEL_CONNECTION_FAILED guide covers that family.
Requests says Max retries exceeded although it retries nothing by default: the lab proxy received one CONNECT per request. Node hides the status two levels down, so log the whole cause chain.
Two cases break the rule. For a plain http:// URL there is no tunnel, so curl, Requests, HTTPX, aiohttp and Playwright return the proxy's own 502 as a response; compare its Server header and body with the site's usual ones. Node's fetch tunnels http:// URLs too, so there it stays an exception. And a proxy that closes the connection without a status gives no number at all: curl printed curl: (56) Proxy CONNECT aborted, Requests raised ConnectionError with RemoteDisconnected('Remote end closed connection without response'), HTTPX RemoteProtocolError: Server disconnected without sending a response. and Chromium net::ERR_EMPTY_RESPONSE.
Check with one curl request
Print both status codes for the failing URL through the same proxy:
curl --disable -sS -o /dev/null -x "$PROXY_URL" \
-w 'connect=%{http_connect} http=%{http_code} exit=%{exitcode}\n' \
https://example.com/connect=502 http=000: the proxy refused the tunnel; see the causes below.connect=200 http=502: the website answered 502; see when the website sent it.connect=000withProxy CONNECT aborted: the proxy closed the connection without a status. Check the port first.
Causes on the proxy side
The site is unreachable from the exit (502). The proxy's onward connection failed; in the lab a refused one became 502 Bad Gateway in every client. Request the URL without the proxy and through another exit or country. If only one exit fails, the site is down or blocking that network from there, so switch exits. If every exit fails, check the host and port in your CONNECT line.
The exit is offline or rotating (503, sometimes 502). The gateway has nowhere to send the request. Retry idempotent requests with backoff, as below, and check the exit's status in your provider's dashboard.
The proxy gave up waiting (504). The proxy's deadline for reaching the site expired. The lab proxy waited 3 seconds, and every client failed after 3.0 to 3.5 seconds. Keep your client timeout longer than the proxy's deadline, or you see your own timeout instead of the 504; the proxy timeout guide separates the two.
Wrong port or protocol. We pointed every client, as an HTTP proxy, at a port that speaks only SOCKS5 (pproxy 2.7.9). The server closed the connection without a reply, so there was no 502 at all: curl 8.5.0 and 8.18.0 printed curl: (56) Proxy CONNECT aborted, aiohttp raised ServerDisconnectedError, and Node's fetch did not settle until its 10-second timeout fired. Copy the scheme, host and port from your provider's setup page. An https:// proxy URL on a plain HTTP port fails differently; see SSL WRONG_VERSION_NUMBER behind a proxy.
Provider capacity. A gateway that is out of capacity can answer 502 or 503 to new tunnels. Reduce concurrency, back off and send your provider the failure times in UTC.
At the same step, a 407 is about authentication (fix proxy error 407) and a 403 is a deliberate refusal (CONNECT tunnel failed, response 403).
Mobile proxies and IP rotation
On a mobile proxy the exit is a device on a carrier network. While it changes IP there can be a moment with no working exit; depending on the gateway, the client then sees a 502 or 503 on CONNECT or a closed connection, reported as above. We did not measure a live rotation, so this guide gives no durations. If you start the rotation yourself, pause requests until a cheap test request succeeds.
The lab simulated an exit that is offline for 4 seconds, with one Requests call per second. Seconds 0 to 3 failed with Tunnel connection failed: 503 Service Unavailable; from second 4 every request returned 200. The 4 seconds are the lab's setting: this shows the shape of a rotation, not its length.
Built-in retries treat the two sides differently. curl --retry 2 sent the CONNECT to the failing proxy once and stopped, but asked the website three times for its 502; with --retry-all-errors it repeated the CONNECT too. urllib3's Retry(total=2, status_forcelist=[502, 503, 504]) repeated both. Node's fetch retried nothing after a status, but when the proxy closed the connection without one it reconnected 160,357 times in 10 seconds, until an AbortSignal.timeout stopped it. Always pass a signal.
Retry GET requests only. A failed CONNECT never reached the site, but a 502 from the site can arrive after the site did the work, and a retried POST then runs twice; see one lost response, two created jobs. This helper retries a GET after a proxy or site 502, 503 or 504 with capped, jittered backoff and stops on anything else, such as a 407:
import random, re, time
import requests
RETRYABLE = {502, 503, 504}
TUNNEL_STATUS = re.compile(r"Tunnel connection failed: (\d{3})")
def get_with_retry(session, url, attempts=5, base_delay=1.0, max_delay=8.0, **kwargs):
for attempt in range(1, attempts + 1):
try:
response = session.get(url, **kwargs)
except requests.exceptions.ProxyError as exc:
match = TUNNEL_STATUS.search(str(exc)) # Requests reports the CONNECT status only as text
if match is None or int(match.group(1)) not in RETRYABLE or attempt == attempts:
raise
else:
if response.status_code not in RETRYABLE or attempt == attempts:
return response
time.sleep(min(max_delay, base_delay * 2 ** (attempt - 1)) * random.uniform(0.5, 1.0))In the recorded run it got through the 4-second offline window on its fourth attempt, after 5.7 seconds. Against a site that kept answering 502 it gave up after five attempts and 9.9 seconds and returned the 502.
When the website sent it
If the tunnel opened, the proxy did its job: the 502 came from the website's gateway, load balancer or CDN, which got no usable answer from the application behind it. Changing proxies or rotating the IP does not fix that. Request the URL without the proxy; if that fails too, wait and retry GETs with the helper above. If it fails only through the proxy, the site may be serving errors to that network; compare body and headers with a direct request before changing exits.
Reproduce the lab
The download folder holds lab.mjs (a proxy on 127.0.0.1 that answers CONNECT with 502, 503 or 504 or hangs up, and a TLS origin that answers 502 itself), one script per client, wrong_port.mjs, node_checks.mjs and retry_get.py with the helper above. results.json records the versions: curl 8.5.0 and 8.18.0, Python 3.14.4, Requests 2.34.2, urllib3 2.8.0, HTTPX 0.28.1, aiohttp 3.14.5, Node.js 24.22.1 with undici 7.30.1, and Playwright 1.63.0 with Chromium 153. Not tested: macOS and Windows, SOCKS and HTTPS proxies, and real provider gateways.
Inside a coding agent, Proxy Toolkit MCP offers error diagnostics; give it the client and the sanitized error, never credentials.
ipvolt mobile proxies are dedicated devices on carrier networks; you rotate the IP on demand or on a schedule, so a rotation never takes you by surprise.
Sources & further reading
Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.
- RFC 9110: CONNECT
- RFC 9110: 502 Bad Gateway
- RFC 9110: 503 Service Unavailable
- RFC 9110: 504 Gateway Timeout
- curl manual: --retry
- curl manual: --retry-all-errors
- urllib3 documentation: Retry
- Requests documentation: proxies
- HTTPX documentation: proxies
- aiohttp documentation: proxy support
- Playwright: HTTP proxy
- Node.js CLI documentation: NODE_USE_ENV_PROXY
- pproxy on PyPI