把凭据写进代理 URL,然后通过 proxy 参数传入:
from curl_cffi import requests
PROXY = "http://user:pass@proxy.example.com:8080"
with requests.Session(impersonate="chrome", proxy=PROXY) as session:
response = session.get("https://example.com/")
print(response.status_code)要捕获代理故障,需要同时捕获 ProxyError 和 ConnectionError,并检查 status_code 是否为 407。在 curl_cffi 0.16 中,代理拒绝 CONNECT 时抛出的是 ConnectionError,不是 ProxyError。
在我们的实验环境里,这段代码对 https:// 和 http:// 请求都通过了一个要求 Basic 认证的本地代理。下文的所有内容记录于 2026 年 10 月 10 日,环境是 Python 3.14.4 上的 curl_cffi 0.16.3(libcurl 8.21.0)。impersonate 用来选择浏览器配置,curl_cffi 会照它设置 TLS 和 HTTP/2 参数。下面的每个结果在使用和不使用它时都相同。
讲错误之前先说三个细节:
proxy和proxies只能用一个。proxies={"http": PROXY, "https": PROXY}在单个请求和Session上效果相同。字典的键是所请求 URL 的协议:只有"https"键时,http://请求走了直连。同时传入两个参数会抛出TypeError: Cannot specify both 'proxy' and 'proxies'。- URL 里的密码要做百分号编码。
p@ss:w/rd写成p%40ss%3Aw%2Frd可以正常使用。不编码时,请求抛出ProxyError,信息为curl: (5) Unsupported proxy syntax。代理 URL 不带凭据、另外传proxy_auth=("user", "p@ss:w/rd")也能用,而且不需要编码。 - HTTP 代理要写
http://,请求https://URL 时也一样。 这个协议描述的是你和代理之间的连接。把https://代理 URL 指向实验环境里的普通 HTTP 代理时,抛出了带curl: (35) TLS connect error和WRONG_VERSION_NUMBER的SSLError。SSL WRONG_VERSION_NUMBER:代理哪一跳出错解释了这条信息。
CONNECT tunnel failed, response 407:是 ConnectionError,不是 ProxyError
curl_cffi.requests.exceptions.ConnectionError: Failed to perform, curl: (7) CONNECT tunnel failed, response 407. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.对于 https:// URL,curl_cffi 会先向代理发送 CONNECT。实验代理返回了 407,请求抛出了上面的错误。不带凭据和密码错误时,这条信息完全相同。
isinstance(error, ProxyError)为False。ProxyError和ConnectionError是RequestException下的两个平级子类,所以except ProxyError捕获不到它。error.code是 7。error.response是一个Response,它的status_code是 0,不是 407。状态码只出现在错误信息里。- 代码 7 并不专指 407。当实验代理连不上目标时,它返回 502,信息是
curl: (7) CONNECT tunnel failed, response 502。代理端口无人监听时,信息是curl: (7) Failed to connect to 127.0.0.1:8443 over proxy 127.0.0.1 after 0 ms: Could not connect to server。
异常类取决于 curl 的错误代码,而这个代码在不同版本之间变过。我们在十二个版本上运行了同一个请求:
| curl_cffi | libcurl | CONNECT 被拒绝 | SOCKS 失败 |
|---|---|---|---|
| 0.16.0 到 0.16.3 | 8.21.0 | ConnectionError,curl 代码 7 | ProxyError,curl 代码 97 |
| 0.7.4 到 0.15.0 | 8.7.0 到 8.15.0 | ProxyError,curl 代码 56 | ProxyError,curl 代码 97 |
直到 0.15.0,信息都是 curl: (56) CONNECT tunnel failed, response 407。curl 命令行在 8.18.0 把退出码从 56 改成了 7。不靠猜测修复代理错误 407 里有那张表,以及检查凭据的顺序。
下面这段代码能处理变化前后两种版本里被拒绝的 CONNECT,以及 SOCKS 失败和 407 响应:
import re
from curl_cffi.requests.exceptions import ConnectionError, ProxyError
CONNECT_STATUS = re.compile(r"CONNECT tunnel failed, response (\d{3})")
class ProxyFailure(Exception):
pass
def fetch(session, url):
try:
response = session.get(url)
except ProxyError as error:
raise ProxyFailure(f"proxy error, curl {error.code}") from error
except ConnectionError as error:
rejected = CONNECT_STATUS.search(str(error))
if error.code == 7 and rejected:
raise ProxyFailure(f"proxy answered CONNECT with {rejected[1]}") from error
raise
if response.status_code == 407:
raise ProxyFailure("proxy answered 407")
return response在实验里,它分别报告了 proxy answered CONNECT with 407、proxy answered CONNECT with 502、针对 http:// URL 的 proxy answered 407,以及针对 SOCKS 失败的 proxy error, curl 97。代理端口关闭时,它把 ConnectionError 原样重新抛出。
http:// URL 的 407:是响应,不是异常
对于 http:// URL,没有隧道。curl_cffi 用普通的 GET 把完整 URL 发给代理,从 0.7.0b7 起就是这样(issue #326),代理的 407 会作为响应返回。
没有抛出任何异常。response.status_code 是 407,response.ok 是 False,响应头里有 Proxy-Authenticate: Basic realm="lab"。只捕获异常的代码会把它当成一次成功但响应体为空的请求。
response.raise_for_status() 抛出了 curl_cffi.requests.exceptions.HTTPError: HTTP Error 407: Proxy Authentication Required。这个异常的 error.response.status_code 是 407。
环境变量:读取 http_proxy,不读取 HTTP_PROXY
代码里没有设置代理时,libcurl 会从环境变量里取:
- 对于
http://URL,大写的HTTP_PROXY被忽略,请求走了直连。小写的http_proxy让请求走了代理。 - 对于
https://URL,HTTPS_PROXY和https_proxy都生效。只设置http_proxy时,https://请求走了直连。 ALL_PROXY和all_proxy对两种协议都生效。NO_PROXY=127.0.0.1和小写的no_proxy让请求走了直连。
libcurl 的文档写明了这个例外:这些变量的大写形式也会被检查,"except the http_proxy one which is only used lowercase"(libcurl 环境变量)。原因在于 CGI:服务器会把请求头 Proxy: 变成脚本的 HTTP_PROXY 变量(Everything curl)。
Scrapy 会读取大写的 HTTP_PROXY(Scrapy 2.19 代理配置),而 aiohttp 在你传入 trust_env=True 之前不读取任何变量(aiohttp 3.14 代理配置)。代理环境变量对比了更多客户端。
有两个结果容易被忽略:
NO_PROXY会覆盖proxy参数。 设置了NO_PROXY=127.0.0.1时,requests.get(url, proxy=PROXY)在两种协议下都走了直连。加上curl_options={CurlOpt.NOPROXY: ""}后,请求重新走了代理。和http_proxy冲突时,以参数为准。trust_env=False没有关掉这些变量。Session(trust_env=False)仍然使用了http_proxy和HTTPS_PROXY。proxy=""也没有关掉它们。
在设置了这些变量的情况下,有两种写法让请求走了直连:
from curl_cffi import CurlOpt, requests
requests.get("https://example.com/", proxies={"all": ""})
requests.get("https://example.com/", curl_options={CurlOpt.NOPROXY: "*"})两者也都可以作为 Session 的参数。空的代理字符串 "explicitly disables the use of a proxy, even if there is an environment variable set for it"(CURLOPT_PROXY)。
SOCKS:socks5:// 在本地解析,socks5h:// 发送主机名
实验通过一个记录每个目的地的 SOCKS5 服务器请求 http://dnsprobe.example:8080/,这个名字只在 hosts 文件里存在,对应 127.0.0.2:
- 使用
socks5://127.0.0.1:1080时,服务器记录了CONNECT atyp=IPv4 dest=127.0.0.2:8080。curl_cffi 自己解析了这个名字。 - 使用
socks5h://127.0.0.1:1080时,服务器记录了CONNECT atyp=DOMAIN dest=dnsprobe.example:8080。代理收到的是主机名。
https:// URL 得到同样的两个结果。这是 curl 自身的约定(在 curl 中使用代理)。socks5 与 socks5h 列出了不遵循这个约定的客户端。
SOCKS 握手失败才是真正抛出 ProxyError 的情况:
curl_cffi.requests.exceptions.ProxyError: Failed to perform, curl: (97) cannot complete SOCKS5 connection to 127.0.0.1. (1). See https://curl.se/libcurl/c/libcurl-errors.html first for more details.error.code 是 97。括号里的最后一个数字是 SOCKS 服务器返回的应答码:实验服务器返回 1,即一般性失败。目标端口关闭、服务器返回 5 时,信息以 (5) 结尾。
协议和端口不匹配时,失败方式又不一样:
- 把
socks5://指向 HTTP 代理端口,抛出带curl: (97) Received invalid version in initial SOCKS5 response.的ProxyError。 - 把
http://指向 SOCKS 端口,请求https://URL 时抛出带curl: (56) Proxy CONNECT aborted的ProxyError。 - SOCKS 端口无人监听时,错误又是代码为 7 的
ConnectionError。
AsyncSession:同样的配置,同样的错误
import asyncio
from curl_cffi import AsyncSession
PROXY = "http://user:pass@proxy.example.com:8080"
async def main():
async with AsyncSession(impersonate="chrome", proxy=PROXY) as session:
response = await session.get("https://example.com/")
print(response.status_code)
asyncio.run(main())每个异步用例都和对应的同步用例一致。被拒绝的 CONNECT 抛出同样的代码为 7 的 ConnectionError。http:// URL 把 407 作为响应返回。SOCKS 失败抛出代码为 97 的 ProxyError。socks5:// 和 socks5h:// 发送的地址类型相同,HTTP_PROXY 被忽略,而 http_proxy 生效。
重试:retry 会重发 POST 请求
curl_cffi 默认不重试。实验通过代理向一个目标发送 POST,这个目标读完请求后不作应答就关闭连接。调用抛出了带 curl: (52) Empty reply from server 的 ConnectionError,目标收到了一次 POST。
使用 requests.Session(proxy=PROXY, retry=1) 时,同样的调用返回了 201,而目标收到了两次请求体。retry 重发 POST 的方式和重发 GET 一样。
CONNECT 被拒绝时,retry=2 发送了三次 CONNECT 请求并抛出同样的错误,Session 和 AsyncSession 都是如此。重试修复不了错误的凭据。
对于不能安全重发的请求,把 retry 保持为 0,或者发送幂等键。代理重试:丢失一个响应,创建了两个任务展示了幂等键能改变什么。如果想在编码代理中获得诊断帮助,Proxy Toolkit MCP 接受经过清理的错误描述。不要把凭据放进它的参数里。
测试方法
在 Ubuntu 26.04.1 上,使用 Python 3.14.4 和 curl_cffi 0.16.3(libcurl 8.21.0-IMPERSONATE,BoringSSL),一切都在回环地址上:python -m http.server 作为 HTTP 和 HTTPS 目标,三个要求 Basic 认证并记录每个请求的代理,一个记录每个目的地的 SOCKS5 服务器和一个对每个请求都返回应答码 1 的 SOCKS5 服务器,以及一个会丢掉第一次 POST 响应的目标。dnsprobe.example 只存在于 hosts 文件的一份私有副本里。86 个用例中的每一个都在新的解释器和干净的环境中运行。版本对比安装了从 0.7.4 到 0.16.3 的十二个版本。没有使用任何代理账号。
实验归档包含运行脚本、用例、代理、SOCKS5 服务器、README、记录的结果和版本对比。
未测试:macOS 和 Windows、Digest 和 NTLM 代理认证、需要认证的 SOCKS 代理、需要通过 TLS 连接的代理、HTTP/3 和 WebSocket。
ipvolt 是一项面向开发者的代理服务,目前尚未开放;加入早期访问名单,开放时你会收到一封邮件。
参考来源与延伸阅读
本指南参考的技术资料。请以你所安装版本的文档以及代理服务商支持的配置为准。
- curl_cffi documentation: Proxies
- curl_cffi API reference: Session (proxy, proxies, proxy_auth, trust_env, retry)
- curl_cffi API reference: AsyncSession
- curl_cffi API reference: Exceptions and warnings (ProxyError, ConnectionError)
- curl_cffi documentation: Using CURLOPT_* in requests API (curl_options)
- curl_cffi issue #326: CONNECT was sent for http:// URLs, changed in 0.7.0b7
- libcurl: CURLOPT_PROXY (schemes, the empty string, socks5h)
- libcurl: CURLOPT_NOPROXY and the no_proxy variable
- libcurl environment variables: http_proxy is only used lowercase
- Everything curl: proxy environment variables and the CGI reason
- libcurl error codes: CURLE_COULDNT_CONNECT (7), CURLE_RECV_ERROR (56), CURLE_PROXY (97)