Python requests 遇到 BAD_ECPOINT 的终极解决方案
一句话总结
SSLError(1, '[SSL: BAD_ECPOINT] bad ecpoint') 发生在 TLS 握手层 ,与证书验证无关,verify=False 无效。根因是目标服务器使用 ECDH 压缩曲线,OpenSSL 1.1+ 拒绝握手。Windows schannel 无此限制所以本地正常。最终方案:用 curl_cffi 替代 requests,绕过 Python _ssl 模块。
问题场景
湖南生态环境厅网站 sthjt.hunan.gov.cn,在 Docker 容器(python:3.12-slim-bullseye,OpenSSL 1.1)中请求时直接报错:
requests.exceptions.SSLError: HTTPSConnectionPool(host='sthjt.hunan.gov.cn', port=443):
Max retries exceeded (Caused by SSLError(SSLError(1, '[SSL: BAD_ECPOINT] bad ecpoint (_ssl.c:1000)')))
但 Windows 本地运行完全正常。
根因分析
为什么 verify=False 无效?
SSL/TLS 握手分为两层:
┌─────────────────────────────────┐
│ 证书验证层 (verify=True/False) │ ← verify=False 只跳过这一层
├─────────────────────────────────┤
│ TLS 握手层 (密钥交换/加密套件) │ ← BAD_ECPOINT 发生在这里
└─────────────────────────────────┘
BAD_ECPOINT 发生在 TLS 握手阶段的 ECDH 密钥交换环节------服务器返回了压缩格式的椭圆曲线点(compressed EC point),OpenSSL 1.1.0+ 出于安全考虑拒绝接受。这是握手层面的拒绝,比证书验证更底层。
为什么 Windows 正常,Linux 报错?
Python 的 SSL/TLS 能力来自 CPython 编译时链接的 SSL 后端:
| 平台 | SSL 后端 | 对压缩 EC 点的态度 |
|---|---|---|
| Windows | schannel(系统自带) | 容忍 |
| Linux (OpenSSL 1.1+) | OpenSSL | 拒绝 |
| Linux (OpenSSL 3.x) | OpenSSL | 拒绝 |
| macOS | Secure Transport | 容忍 |
关键点 :Python 的 _ssl 模块是 C 扩展,在 CPython 编译时就链接好了,运行时无法替换 。apt install openssl 或 pip install pyOpenSSL 都改变不了 _ssl 的行为。
尝试过的无效方案
按时间顺序,以下方案均失败:
| 方案 | 结果 | 原因 |
|---|---|---|
verify_ssl = False |
❌ | 错误在握手层,不在证书层 |
_LegacySSLAdapter + SECLEVEL=1 |
❌ | 安全级别与 EC 点压缩是正交问题 |
_LegacySSLAdapter + SECLEVEL=0 + OP_LEGACY_SERVER_CONNECT |
❌ | 同上,OpenSSL 1.1 根本不允许压缩 EC 点 |
降级到 python:3.12-slim-bullseye(OpenSSL 1.1) |
❌ | OpenSSL 1.1.0 开始就拒绝压缩 EC 点了 |
| 云端浏览器 API 代理 | ❌ | API 服务端自身崩溃(api_server.py 内部报错) |
最终方案:curl_cffi
原理
curl_cffi 通过 CFFI 直接调用 libcurl,完全不经过 Python 的 _ssl 模块 。TLS 握手由 curl 自己的 SSL 后端处理,从根本上规避了 Python _ssl 的限制。
此外它还支持 TLS 指纹伪装(模拟 Chrome/Firefox/Safari 的 JA3 指纹),顺带解决基于 TLS 指纹的反爬。
安装
bash
pip install curl_cffi
支持 Python 3.10+,有预编译的 manylinux/musllinux wheel,无需编译。
代码改动
改动量极小------curl_cffi 的 API 与 requests 完全兼容:
python
# 之前
import requests
session = requests.Session()
# 之后
from curl_cffi import requests
session = requests.Session(impersonate="chrome124") # 模拟 Chrome 124 TLS 指纹
完整代码(湖南 pipeline 实际改动):
python
from curl_cffi import requests
class HunanPipeline(BasePipeline):
province = "湖南"
def _create_session(self) -> requests.Session:
"""使用 curl_cffi Session(Chrome 指纹 + libcurl TLS,绕过 BAD_ECPOINT)"""
session = requests.Session(impersonate="chrome124")
session.headers.update({
"User-Agent": "Mozilla/5.0 ... Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
})
return session
def collect(self, session, year_filter, first_page_only=False):
# ... session.get(url) 用法完全不变
resp = session.get(url, timeout=30)
def process_item(self, session, item):
# ... 同样不变
resp = session.get(item["url"], timeout=30)
不需要 verify_ssl = False、不需要 _LegacySSLAdapter、不需要改 Dockerfile 基础镜像。
注意事项
curl_cffi的异常类继承自自己的curl_cffi.requests.errors.RequestsError,不是 标准库的requests.RequestException。如果基类用except requests.RequestException做重试,需要改为同时捕获两者的基类,或用except Exception兜底。impersonate参数可选的浏览器版本:chrome110、chrome120、chrome124、firefox、safari等。国内政府网站建议用chrome124。- 详情页的超时时间建议 > 30s(政府网站响应可能较慢)。
推广适用场景
这个方案不仅适用于 BAD_ECPOINT,同样的思路可以解决其他 Python _ssl 层面的问题:
SSLError: unsupported protocol--- 服务器仅支持老旧 TLS 版本SSLError: dh key too small--- DH 密钥长度不足SSLError: no shared cipher--- 加密套件不兼容- TLS 指纹反爬(JA3 检测)
只要问题根因在 Python _ssl 模块层面,且无法通过升级/配置 OpenSSL 解决,就可以考虑用 curl_cffi 绕开。
参考
- 博客方案参考:解决Python报错SSLError,如果试了网上一大堆方法还不行,看看这个吧!! --- 感谢 kikn0928 的分享
- curl_cffi GitHub
- curl_cffi PyPI