故障排查阅读约 8 分钟

SSL WRONG_VERSION_NUMBER:代理哪一跳出错

使用代理时,同一个 SSL wrong version number 错误可能出在两跳:代理端口或目标端口。教你在 curl、Requests 和 HTTPX 中判断是哪一跳并修复。

本页内容

wrong version number 是 OpenSSL 在发起 TLS 握手后、发现响应的前几个字节不是 TLS 时报告的错误。实际上那些字节是明文 HTTP:客户端向一个以明文应答的端口发送了 ClientHello,而 HTTP/1.1 400 Bad Request 这样的响应无法被解析为 TLS 记录。这与证书、协议版本或加密套件都无关,所以 --tlsv1.2、--insecure 和 verify=False 都不会带来任何改变。

使用代理时,这次握手可能发生在两个位置,而大多数客户端对两者打印的文字完全相同:

  1. 代理这一跳。 代理 URL 写的是 https://,于是客户端与代理本身开始 TLS,但代理端口说的是明文 HTTP。
  2. 目标这一跳。 代理 URL 写的是 http://,CONNECT 成功,客户端通过隧道与一个不支持 TLS 的目标端口开始 TLS,例如 https://example.com:80/。
客户端情况 1:代理这一跳情况 2:目标这一跳
curl 8.18.0curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number同一行
Requests 2.34.2ProxyError:Unable to connect to proxy. Your proxy appears to only use HTTP and not HTTPS, try changing your proxy URL to be HTTP.SSLError:[SSL: WRONG_VERSION_NUMBER] wrong version number,没有提示
HTTPX 0.28.1ConnectError: [SSL: WRONG_VERSION_NUMBER] wrong version number同一行

只有 Requests 会指明是哪一跳。使用 curl 和 HTTPX 时还需要多做一步检查,下文会说明。Python 两行来自搭配 OpenSSL 3.0.13 的 Python 3.12.3;较新的 OpenSSL 构建对同一故障的措辞不同,本页最后一张表列出了记录到的全部字符串。

如何区分这两跳

先看代理 URL。 情况 1 要求代理 URL 以 https:// 开头。打印进程实际使用的值,包括不是你自己设置的变量:

sh
env | grep -i _proxy

如果所有代理 URL 都已经以 http:// 开头,那就是情况 2。

curl:读取 CONNECT 状态。 %{http_connect} 是代理对 CONNECT 的响应状态码。为零表示 curl 根本没有走到那一步。

sh
curl --silent --show-error --output /dev/null \
  --proxy "$PROXY_URL" \
  --write-out 'connect=%{http_connect} exit=%{exitcode}\n' \
  https://example.com/
  • connect=000 exit=35:与代理的握手失败。情况 1。
  • connect=200 exit=35:隧道已打开,与目标的握手失败。情况 2。

curl -v 显示的是同一件事。情况 2 中,这几行出现在错误之前;情况 1 中完全没有 CONNECT 行:

code
> CONNECT 127.0.0.1:18000 HTTP/1.1
< HTTP/1.1 200 Connection established
* CONNECT tunnel established, response 200
* TLS connect error: error:0A00010B:SSL routines::wrong version number

详细输出还会打印 Proxy-Authorization 请求头,所以分享时请发 write-out 那一行,而不是完整跟踪。

Requests:看异常类。 requests.exceptions.ProxyError 表示到代理的连接失败。requests.exceptions.SSLError 表示隧道打开之后 TLS 失败。

python
import requests

proxy_url = "http://USERNAME:PASSWORD@proxy.example.net:8080"
proxies = {"http": proxy_url, "https": proxy_url}
try:
    requests.get("https://example.com/", proxies=proxies, timeout=(10, 20))
except requests.exceptions.ProxyError as error:
    print("proxy hop:", type(error).__name__)
except requests.exceptions.SSLError as error:
    print("target hop:", type(error).__name__)

这条提示来自 urllib3,它只在代理 URL 的协议是 https 且 TLS 错误包含 wrong version number、unknown protocol 或 record layer failure 时才添加。旧版本没有这么严谨:urllib3 1.26.8 对 http:// 代理 URL 也会打印这条提示(issue #2564)。在旧版 urllib3 上,请相信你打印出来的代理 URL,而不是提示。

HTTPX:更换协议,或查看 httpcore 日志。 HTTPX 对两种情况抛出相同的 ConnectError。如果代理 URL 是 https://,把它改成 http:// 再运行一次:错误消失说明是情况 1,同样的错误仍在则是情况 2。想直接得到答案,可以打开调试日志:

python
import logging

logging.basicConfig(level=logging.DEBUG)

情况 2 会记录一次返回 200 的 CONNECT,随后是 httpcore.proxy 中的失败:

code
DEBUG:httpcore.http11:receive_response_headers.complete return_value=(b'HTTP/1.1', 200, b'Connection established', [])
DEBUG:httpcore.proxy:start_tls.failed exception=ConnectError(SSLError(1, '[SSL: RECORD_LAYER_FAILURE] record layer failure (_ssl.c:1081)'))

情况 1 不会记录 CONNECT,失败的那一行是 httpcore.connection:start_tls.failed。这段日志来自搭配 OpenSSL 3.5.5 的 Python 3.14.4,所以显示的是 RECORD_LAYER_FAILURE。

修复情况 1:代理 URL 写 http://

代理 URL 中的协议描述的是客户端如何与代理通信,而不是它通过代理获取什么。http:// 代理 URL 照样可以承载 HTTPS 流量:客户端以明文发送 CONNECT,然后在隧道内与目标进行 TLS。只有当服务商在文档中明确提供 TLS 代理端口时才使用 https://。curl 的 --proxy 文档把不带协议或 http:// 视为 HTTP 代理,把 https:// 视为 HTTPS 代理。

sh
curl --proxy http://USERNAME:PASSWORD@proxy.example.net:8080 https://example.com/
python
import requests

proxy_url = "http://USERNAME:PASSWORD@proxy.example.net:8080"
response = requests.get(
    "https://example.com/",
    proxies={"http": proxy_url, "https": proxy_url},
    timeout=(10, 20),
)
python
import httpx

with httpx.Client(proxy="http://USERNAME:PASSWORD@proxy.example.net:8080") as client:
    response = client.get("https://example.com/")
sh
export HTTPS_PROXY=http://USERNAME:PASSWORD@proxy.example.net:8080
export HTTP_PROXY=http://USERNAME:PASSWORD@proxy.example.net:8080

在实验中,用 https:// 失败的那个代理端口,在协议改为 http:// 之后(无论写在代码里还是通过 HTTPS_PROXY 设置),三个客户端都返回了 200。如果改完之后错误依旧,可能是某个环境变量仍在提供旧值:Requests 和 HTTPX 默认都会读取 HTTPS_PROXY(trust_env)。代理环境变量说明了各客户端读取哪个变量,curl、Requests 和 HTTPX 指南则给出了完整配置。

陷阱:字典的键不是代理的协议

在 proxies={"https": ...} 中,键是你所请求 URL 的协议。值是代理 URL,它有自己的协议。两者不必一致,而对于明文 HTTP 代理端口,两者一定不能一致:

python
# Case 1: TLS to a proxy port that speaks plain HTTP
proxies = {"https": "https://USERNAME:PASSWORD@proxy.example.net:8080"}

# Correct: HTTPS targets through an HTTP proxy
proxies = {"https": "http://USERNAME:PASSWORD@proxy.example.net:8080"}

环境变量也是同样的道理。HTTPS_PROXY 这个名字决定哪些请求走代理,它的值说明如何连接代理。

修复情况 2:改正目标 URL

代理没有问题。是 URL 在一个提供明文 HTTP 的端口上要求 TLS。常见原因是 https:// 搭配 80 端口或某个内部 HTTP 端口,以及基础 URL 由分开配置的协议和端口拼接而成。通过同一条隧道发送明文 HTTP 来确认:

sh
curl --silent --show-error --proxy "$PROXY_URL" --proxytunnel \
  --write-out 'connect=%{http_connect} status=%{http_code}\n' \
  http://example.com:80/

在实验中,对于用 https:// 失败的那个端口,这条命令打印出了页面和 connect=200 status=200,而真正支持 TLS 的端口给出的是 curl: (52) Empty reply from server。这里出现 HTTP 状态码就说明该端口说的是明文 HTTP:把 URL 改成 http://,或者指向 TLS 端口。如果允许你不经代理直接连接,同一个 URL 直连时也会以同样的方式失败,这也排除了代理的嫌疑。

如果 URL 没错而错误时有时无,说明从隧道返回的字节并非来自目标的 TLS 服务器。urllib3 的 issue #2564 报告的正是不稳定代理下的这种现象。请记录带时间戳的 connect= 行并发给服务商。

重试解决不了这两种情况

两种情况都是配置错误,每次尝试都会以同样的方式失败。Requests 消息里的 Max retries exceeded 并不代表真的重试过:默认适配器配置为零次重试,却照样打印这段文字。重试策略只会推迟同一个异常。先改正 URL,把重试留给那些在两次尝试之间可能发生变化的故障。

在兼容 MCP 的编程智能体中,免费的 Proxy Toolkit MCP 可以按请求阶段诊断代理错误。把客户端、异常和 connect= 的值告诉它,不要附带凭据。

错误文字取决于 OpenSSL 构建

错误文字来自 TLS 库,而不是客户端,所以同一故障在不同构建上的表述不同。除另有说明外,每个构建在两跳上打印的字符串相同:

构建错误文字
curl 8.18.0 (OpenSSL 3.5.5)、curl 8.17.0 (OpenSSL 3.6.0)、curl 8.22.0 (OpenSSL 4.0.2)curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
curl 8.12.1 (OpenSSL 3.4.1)curl: (35) TLS connect error: error:0A0000C6:SSL routines::packet length too long
curl 8.7.1 (OpenSSL 3.2.1)curl: (35) OpenSSL/3.2.1: error:0A0000C6:SSL routines::packet length too long
Python 3.11.3 (OpenSSL 1.1.1s)、Python 3.12.3 (OpenSSL 3.0.13)[SSL: WRONG_VERSION_NUMBER] wrong version number
Python 3.14.4 (OpenSSL 3.5.5)[SSL: RECORD_LAYER_FAILURE] record layer failure
Node.js 24.20.0 fetch (OpenSSL 3.5.7)TypeError: fetch failed,cause 的 code 为 ERR_SSL_WRONG_VERSION_NUMBER
Playwright 1.63.0 (Chromium 153)情况 1:net::ERR_PROXY_CONNECTION_FAILED。情况 2:net::ERR_SSL_PROTOCOL_ERROR

也就是说,链路中有代理时出现的 packet length too long 和 record layer failure 同样是这两种情况。在三个 Python 构建上,Requests 在情况 1 中都抛出带提示的 ProxyError,在情况 2 中都抛出不带提示的 SSLError。urllib3 查找的第三个短语 unknown protocol 在所有测试过的构建上都没有出现。Chromium 是这里唯一能自行区分两跳的客户端;修复 ERR_TUNNEL_CONNECTION_FAILED 错误介绍了它的代理错误,Node.js fetch failed 指南介绍了如何读取 cause 链。

测试方法

2026 年 10 月 4 日,在一台 Ubuntu 26.04 VPS 上,所有服务都运行在 127.0.0.1:一个对任何输入都以明文 HTTP/1.1 400 Bad Request 应答的监听器、一个最小化的明文 HTTP CONNECT 代理、作为明文 HTTP 目标的 python -m http.server,以及套上一次性证书、作为可用 TLS 目标的同一个处理程序。无需代理账号,也没有任何出站流量。情况 1 把 https:// 代理 URL 分别指向明文监听器和 CONNECT 代理。情况 2 使用 CONNECT 代理,并用 https:// URL 访问明文 HTTP 端口。使用 http:// 代理 URL 和 TLS 目标的对照组在所有客户端中都返回 200。

客户端:上表中的各个 curl 构建;三个 Python 构建上的 Requests 2.34.2(urllib3 2.8.0)和 HTTPX 0.28.1(httpcore 1.0.9);设置 NODE_USE_ENV_PROXY=1 的 Node.js 24.20.0 fetch;搭配 Chromium 153.0.8010.12 的 Playwright 1.63.0。实验归档包含实验脚本、各客户端脚本、README 和记录结果。

未测试:macOS 和 Windows、OpenSSL 与 Chromium 自带库之外的 TLS 库、SOCKS 代理、真正的 TLS 代理端口以及任何商业代理网络。直接关闭连接或保持沉默、不以明文应答的代理端口同样没有测试,可能产生不同的错误。

相关指南

ipvolt 是一项面向开发者的代理服务,目前尚未开放;加入早期访问名单,开放时你会收到一封邮件。

参考来源与延伸阅读

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