# "Tunnel connection failed: 502 Bad Gateway" (and 503, 504) behind a proxy

Source: https://ipvolt.com/guides/fix-proxy-tunnel-502-503
Markdown: https://ipvolt.com/guides/fix-proxy-tunnel-502-503.md

[Home](https://ipvolt.com/index.md) / [Guides](https://ipvolt.com/guides.md) / ["Tunnel connection failed: 502 Bad Gateway" (and 503, 504) behind a proxy](https://ipvolt.com/guides/fix-proxy-tunnel-502-503.md)

Category: Troubleshooting
Published: 2026-11-05
Reading time: 7 minutes
Author: ipvolt

A 502, 503 or 504 during CONNECT comes from the proxy; a 502 after the tunnel opens comes from the site. How to tell them apart in each client and fix both.

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](https://www.rfc-editor.org/rfc/rfc9110.html#name-connect)). 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](/guides/fix-err-tunnel-connection-failed) 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:

```sh
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](#when-the-website-sent-it).
- `connect=000` with `Proxy 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](/guides/proxy-timeout-troubleshooting) 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](/guides/fix-ssl-wrong-version-number-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](/guides/fix-proxy-error-407)) and a 403 is a deliberate refusal ([CONNECT tunnel failed, response 403](/blog/connect-tunnel-failed-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](/blog/proxy-retries-duplicate-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:

```python
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](/downloads/fix-proxy-tunnel-502-503/README.md) 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](/downloads/fix-proxy-tunnel-502-503/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](/mcp) offers error diagnostics; give it the client and the sanitized error, never credentials.

ipvolt [mobile proxies](/pricing) 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

- [RFC 9110: CONNECT](https://www.rfc-editor.org/rfc/rfc9110.html#name-connect)
- [RFC 9110: 502 Bad Gateway](https://www.rfc-editor.org/rfc/rfc9110.html#name-502-bad-gateway)
- [RFC 9110: 503 Service Unavailable](https://www.rfc-editor.org/rfc/rfc9110.html#name-503-service-unavailable)
- [RFC 9110: 504 Gateway Timeout](https://www.rfc-editor.org/rfc/rfc9110.html#name-504-gateway-timeout)
- [curl manual: --retry](https://curl.se/docs/manpage.html#--retry)
- [curl manual: --retry-all-errors](https://curl.se/docs/manpage.html#--retry-all-errors)
- [urllib3 documentation: Retry](https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry)
- [Requests documentation: proxies](https://requests.readthedocs.io/en/latest/user/advanced/#proxies)
- [HTTPX documentation: proxies](https://www.python-httpx.org/advanced/proxies/)
- [aiohttp documentation: proxy support](https://docs.aiohttp.org/en/stable/client_advanced.html#proxy-support)
- [Playwright: HTTP proxy](https://playwright.dev/docs/network#http-proxy)
- [Node.js CLI documentation: NODE_USE_ENV_PROXY](https://nodejs.org/api/cli.html#node_use_env_proxy1)
- [pproxy on PyPI](https://pypi.org/project/pproxy/)

## Related guides

- [Fix proxy error 407 without guessing](https://ipvolt.com/guides/fix-proxy-error-407.md)
- [Fix ERR_TUNNEL_CONNECTION_FAILED in Playwright and Puppeteer](https://ipvolt.com/guides/fix-err-tunnel-connection-failed.md)
- [Troubleshoot proxy timeouts one stage at a time](https://ipvolt.com/guides/proxy-timeout-troubleshooting.md)
- [SSL WRONG_VERSION_NUMBER behind a proxy: which hop failed](https://ipvolt.com/guides/fix-ssl-wrong-version-number-proxy.md)
- [Rotating vs sticky proxies: plan for session continuity](https://ipvolt.com/guides/rotating-vs-sticky-proxies.md)
- [Mobile vs residential proxies: build a useful evaluation](https://ipvolt.com/guides/mobile-vs-residential-proxies.md)

## About ipvolt

Examples use generic proxy settings, with links to the original technical documentation. Product-specific behavior must be checked with your provider. ipvolt is still in development.

## Know when access opens.

ipvolt · In development

We’re building proxy infrastructure for developers and data teams. Join the interest list for a heads-up when ipvolt is ready.

Consent: One email when access opens. Nothing else.

[Get early access](https://ipvolt.com/guides/fix-proxy-tunnel-502-503#waitlist-closing). Use the email form on this page to join the interest list.

[Privacy](https://ipvolt.com/privacy)

