Tunnel connection failed: 502 排查

CONNECT 阶段的 502、503、504 来自代理;隧道建立后的 502 来自网站。本文说明如何在 curl、Python、Node.js 和 Playwright 中区分两者并分别修复。

本页内容

在客户端建立代理隧道的过程中收到的 502 Bad Gateway、503 Service Unavailable 或 504 Gateway Timeout 来自代理:隧道从未建立,网站也从未看到你的请求。隧道建立之后收到的 502 来自网站,或者来自网站前面的某个组件。同一个数字,不同的责任方,不同的修复方法。下文中的每一条错误信息和每一个数字都来自 2026 年 11 月 5 日在本机回环地址上运行的实验,链接见文末。

502 是哪一方发出的

对于 https:// URL,客户端会先用 CONNECT host:443 请求代理建立隧道(RFC 9110)。如果代理返回的不是 2xx,就没有隧道,大多数客户端会抛出异常。如果隧道建立后网站返回 502,你得到的是一个普通响应。

客户端代理拒绝建立隧道网站返回 502
curl 8.5.0退出码 56,curl: (56) CONNECT tunnel failed, response 502退出码 0,%{http_code} 为 502,%{http_connect} 为 200
curl 8.18.0退出码 7,curl: (7) CONNECT tunnel failed, response 502退出码 0,%{http_code} 为 502,%{http_connect} 为 200
Requests 2.34requests.exceptions.ProxyError,消息中包含 Tunnel connection failed: 502 Bad Gatewaystatus_code == 502 的 Response
HTTPX 0.28httpx.ProxyError: 502 Bad Gatewaystatus_code == 502 的 Response
aiohttp 3.14ClientHttpProxyError: 502, message='Bad Gateway'status == 502 的响应
Playwright 1.63page.goto: net::ERR_TUNNEL_CONNECTION_FAILEDpage.goto 正常返回,status() 为 502
Node.js 24 fetchTypeError: fetch failed,err.cause.cause.message 为 Proxy response (502) !== 200 when HTTP Tunnelingresponse.status 为 502

503 和 504 的表现相同,只是数字不同,例如 Tunnel connection failed: 503 Service Unavailable 或 curl: (7) CONNECT tunnel failed, response 504。Chromium 在这三种情况下都报告 net::ERR_TUNNEL_CONNECTION_FAILED,不显示状态码;这一类错误见 ERR_TUNNEL_CONNECTION_FAILED 指南。

Requests 显示 Max retries exceeded,但它默认并不重试:实验代理对每个请求只收到一次 CONNECT。Node 把状态码藏在两层 cause 之下,所以要记录完整的 cause 链。

有两种情况不符合这条规则。对于普通的 http:// URL 没有隧道,因此 curl、Requests、HTTPX、aiohttp 和 Playwright 会把代理自己的 502 当作普通响应返回;请把它的 Server 头和响应体与网站平时的响应进行对比。Node 的 fetch 对 http:// URL 也会建立隧道,所以在 Node 中它仍然是异常。而如果代理不返回任何状态就关闭连接,那就根本没有数字:curl 输出 curl: (56) Proxy CONNECT aborted,Requests 抛出带 RemoteDisconnected('Remote end closed connection without response') 的 ConnectionError,HTTPX 抛出 RemoteProtocolError: Server disconnected without sending a response.,Chromium 报告 net::ERR_EMPTY_RESPONSE。

用一条 curl 请求检查

通过同一个代理,打印出错 URL 的两个状态码:

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:代理拒绝建立隧道;见下文的原因。
  • connect=200 http=502:网站返回了 502;见当 502 来自网站时。
  • connect=000 且出现 Proxy CONNECT aborted:代理没有返回状态就关闭了连接。先检查端口。

代理一侧的原因

从出口无法访问网站(502)。 代理向目标发起的连接失败了;在实验中,被拒绝的连接在所有客户端里都变成了 502 Bad Gateway。请分别不经代理、以及经由其他出口或国家请求该 URL。如果只有一个出口失败,说明网站在那里不可用或屏蔽了那个网络,换一个出口即可。如果所有出口都失败,请检查 CONNECT 行中的主机名和端口。

出口离线或正在轮换(503,有时是 502)。 网关无处转发请求。按下文的方法对幂等请求做带退避的重试,并在服务商的控制面板中检查出口状态。

代理等待超时(504)。 代理连接网站的期限到了。实验代理等待 3 秒,所有客户端都在 3.0 到 3.5 秒后失败。客户端的超时要比代理的期限长,否则你看到的是自己的超时而不是 504;代理超时排查指南讲解了如何区分两者。

端口或协议不对。 我们把所有客户端当作 HTTP 代理,指向一个只支持 SOCKS5 的端口(pproxy 2.7.9)。服务器没有回复就关闭了连接,所以根本没有 502:curl 8.5.0 和 8.18.0 输出 curl: (56) Proxy CONNECT aborted,aiohttp 抛出 ServerDisconnectedError,Node 的 fetch 直到 10 秒超时触发才结束。请从服务商的配置页面复制协议、主机和端口。在普通 HTTP 端口上使用 https:// 代理 URL 会以另一种方式失败;见代理下的 SSL WRONG_VERSION_NUMBER。

服务商容量不足。 容量耗尽的网关可能对新隧道返回 502 或 503。降低并发、等待一段时间,并把失败的 UTC 时间发给服务商。

在同一步骤中,407 是认证问题(修复代理 407 错误),403 是有意拒绝(CONNECT tunnel failed, response 403)。

移动代理与 IP 轮换

移动代理的出口是运营商网络中的一台设备。它更换 IP 时,可能会有片刻没有可用出口;视网关而定,客户端此时会在 CONNECT 阶段看到 502 或 503,或者连接被关闭,报告方式与上文相同。我们没有测量真实的轮换,所以本文不给出时长。如果轮换由你发起,请暂停请求,直到一个低成本的测试请求成功。

实验模拟了一个离线 4 秒的出口,每秒用 Requests 发送一次请求。第 0 到第 3 秒都以 Tunnel connection failed: 503 Service Unavailable 失败;从第 4 秒开始每个请求都返回 200。4 秒是实验自己的设置:它展示的是轮换的形态,而不是时长。

内置的重试对两侧的处理不同。curl --retry 2 只向出错的代理发送了一次 CONNECT 就停止了,却向网站请求了三次 502;加上 --retry-all-errors 后它也会重复 CONNECT。urllib3 的 Retry(total=2, status_forcelist=[502, 503, 504]) 两种都会重复。Node 的 fetch 在收到状态码后不做任何重试,但当代理不返回状态就关闭连接时,它在 10 秒内重新连接了 160,357 次,直到 AbortSignal.timeout 将其停止。请始终传入 signal。

只重试 GET 请求。失败的 CONNECT 从未到达网站,但网站返回的 502 可能出现在网站已经完成工作之后,重试的 POST 就会执行两次;见丢失一个响应,创建两个任务。下面的函数在代理或网站返回 502、503 或 504 后,以有上限、带随机抖动的退避重试 GET,遇到其他情况(例如 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))

在记录的运行中,它在第四次尝试时、5.7 秒后度过了 4 秒的离线窗口。面对持续返回 502 的网站,它在五次尝试、9.9 秒后放弃,并返回该 502。

当 502 来自网站时

如果隧道已经建立,代理就完成了它的工作:502 来自网站的网关、负载均衡器或 CDN,它们没有从后面的应用拿到可用的响应。更换代理或轮换 IP 都解决不了。请不经代理请求该 URL;如果同样失败,就等待并用上面的函数重试 GET。如果只在经过代理时失败,网站可能在向那个网络返回错误;在更换出口之前,把响应体和响应头与直接请求进行对比。

复现实验

下载目录中有 lab.mjs(一个在 127.0.0.1 上运行、对 CONNECT 返回 502、503 或 504 或直接断开连接的代理,以及一个自己返回 502 的 TLS 源站)、每个客户端各一个脚本、wrong_port.mjs、node_checks.mjs,以及包含上面那个函数的 retry_get.py。results.json 记录了版本:curl 8.5.0 和 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(undici 7.30.1)以及 Playwright 1.63.0(Chromium 153)。未测试:macOS 和 Windows、SOCKS 和 HTTPS 代理,以及真实服务商的网关。

在编程智能体中,Proxy Toolkit MCP 提供错误诊断;把客户端和脱敏后的错误信息交给它,切勿提供凭据。

ipvolt 的移动代理是运营商网络上的专用设备;你可以按需或按计划轮换 IP,因此轮换不会让你措手不及。

参考来源与延伸阅读

本指南参考的技术资料。请以你所安装版本的文档以及代理服务商支持的配置为准。