407 指向的是代理认证层。先从这里入手,再考虑修改目标站点请求或添加重试。本指南给出排查顺序、curl、Python 的 requests、httpx、aiohttp 和 urllib3、Playwright 以及 Puppeteer 中的确切症状,并为每个客户端提供一份可用的示例。下面的每一条错误原文和每一份示例,都是在一个要求 Basic 认证的小型环回代理上记录下来的;文末链接了实验环境和记录结果。
弄清是谁在索要凭据
HTTP 407 表示代理要求认证。响应中包含 Proxy-Authenticate 质询,客户端通过 Proxy-Authorization 提供代理凭据,定义见 RFC 9110。目标站点的认证是另一回事:网站自身的登录用的是 401 和 Authorization。把代理密码放进目标站点的 Authorization 请求头,或者填进客户端的网站认证选项,都是错误的修复方式;下面每个客户端的小节都会点名对应的选项,方便你排除它。
407 出现在哪里取决于目标。对于 http:// 目标,代理自己转发请求,因此 407 会作为一个普通响应到达:状态码 407、一个 Proxy-Authenticate 头,有时还有一小段响应体。对于 https:// 目标,客户端会先发送 CONNECT host:443 建立隧道。这一步上的 407 发生在与目标进行任何交换之前,所以大多数客户端会抛出代理或隧道异常,而不是把响应对象交给你。例如 curl 8.15、8.16 和 8.17 会打印 curl: (56) CONNECT tunnel failed, response 407;在我们的运行中,curl 8.18.0 和 8.22.0 打印同样的消息,但退出码是 7。同一个代理、同一组凭据,症状却不同:
| 客户端 | https:// 目标(CONNECT) | http:// 目标 |
|---|---|---|
| requests 2.34 | requests.exceptions.ProxyError,附带 Tunnel connection failed: 407 Proxy Authentication Required | Response,status_code == 407 |
| httpx 0.28 | httpx.ProxyError: 407 Proxy Authentication Required | Response,status_code == 407 |
| aiohttp 3.14 | aiohttp.ClientHttpProxyError: 407, message='Proxy Authentication Required' | 响应的 status == 407 |
| urllib3 2.8 | urllib3.exceptions.ProxyError,附带 Tunnel connection failed: 407 | 响应的 status == 407 |
| Playwright 1.63 | 使用默认的 waitUntil: "load" 时,page.goto 以 Timeout 30000ms exceeded 拒绝,requestfailed 报告 net::ERR_TUNNEL_CONNECTION_FAILED;使用 waitUntil: "commit" 时则以 status() === 407 完成 | page.goto 以状态 407 和代理的错误响应体完成 |
| Puppeteer 25 | page.goto 以 net::ERR_INVALID_AUTH_CREDENTIALS 拒绝(无凭据),或以状态 407 加 net::ERR_HTTP_RESPONSE_CODE_FAILURE 完成(凭据错误) | 以 net::ERR_INVALID_AUTH_CREDENTIALS 拒绝 |
| curl 8.15 至 8.17 | 退出码 56,curl: (56) CONNECT tunnel failed, response 407,%{http_connect} 为 407 | http=407 |
| curl 8.18.0 | 退出码 7,curl: (7) CONNECT tunnel failed, response 407,%{http_connect} 为 407 | http=407 |
如果你看到的是 429、502 或 504 而不是 407,代理错误详解 能帮你判断是哪一层给出的。当 Chromium 报告 CONNECT 失败却不给出状态码时,隧道错误指南 覆盖了其 net::ERR_* 家族中的其余错误。Scrapy 不在表中:Scrapy 2.19 对 https:// URL 抛出 TunnelError: Could not open CONNECT tunnel with proxy,对 http:// URL 的 407 则直接丢弃,只在日志里留下一行 INFO;Scrapy 2.19 代理配置对这两种情况都有说明。curl_cffi 同样不在表中:0.16.3 对 https:// URL 抛出带 curl: (7) CONNECT tunnel failed, response 407 的 ConnectionError,而不是 ProxyError,对 http:// URL 则把 407 作为普通响应返回;curl_cffi 代理配置展示了如何同时处理这两种情况。
一次只检查一个变量
按下面的顺序进行,才能得到有用的复现,而不是一连串互不相关的改动。最常见的意外来自继承的设置;代理环境变量指南 说明了如何查看客户端实际选中了哪个网关。
- 网关:从账户当前的设置说明中原样复制 scheme、主机名和端口。
- 方式:确认该端点期望的是用户名/密码、允许的来源 IP,还是文档中列出的其他认证机制。把密码发到按来源 IP 认证的端口,从一台新机器上得到的就是 407。
- 凭据范围:确认用户名属于该产品或子用户,而不仅仅是控制台的登录账号。
- 编码:把凭据嵌入 URL 时要做百分号编码;客户端支持时优先使用独立的凭据字段。
- 客户端:检查继承的代理设置,并确认实际选中的确实是预期的网关。一个过期的
HTTPS_PROXY、一份.curlrc、容器环境或 CI 密钥都可能指向另一个网关或旧密码。记录客户端解析出的主机和端口,绝不要记录密码。
先从一条 curl 请求开始
在碰应用代码之前,先证明网关、用户名和密码本身是可用的。代理凭据和目标站点凭据是分开的:-U 或 --proxy-user 把凭据发给代理;-u 或 --user 把凭据发给目标站点。用错了参数,代理仍然看不到 Proxy-Authorization,于是回答 407,哪怕密码是对的。
curl --disable -sS -o /dev/null --noproxy '' \
-x "$PROXY_URL" --proxy-user "$PROXY_USERNAME:$PROXY_PASSWORD" \
-w 'http=%{http_code} connect=%{http_connect} exit=%{exitcode}\n' \
https://example.com/connect=200 http=200 表示凭据被接受,问题出在你的应用里。connect=407 加上 CONNECT tunnel failed, response 407 表示账号本身没有被接受;按上面的列表检查网关、认证方式和凭据范围,然后发送文末所述的报告。curl 代理指南 有完整的参数参考。
用 curl 与出错的应用做对比
如果 curl 请求能成功,就把精力集中在应用是如何映射这些设置的。例如在 Playwright 中,代理凭据应放在 proxy.username 和 proxy.password 中,而不是 httpCredentials。下面各客户端的小节列出了每个客户端给出的确切异常或状态,以及各自可用的示例。
如果两个客户端都失败,去掉服务商特有的可选国家或会话修饰参数,测试文档中最简单的凭据格式,再一次加一个修饰符。向服务商确认账户访问权限和该端点的认证说明。不要假设每个服务商都用 407 表示同样的计费或产品访问状况。
Python requests:ProxyError,Tunnel connection failed: 407
Requests 只从代理 URL 中读取代理凭据。没有哪个单独的代理认证参数能撑过 CONNECT 这一步:requests.auth.HTTPProxyAuth 会给请求加上一个 Proxy-Authorization 头,这对 http:// 目标有效,对 https:// 目标则毫无作用,因为隧道是由 urllib3 在这个头被发送之前就打开的。在 headers= 里放一个 Proxy-Authorization 条目也是同样的结果。在记录的运行中,这两种写法对 http://example.com/ 都返回了 200,对 https://example.com/ 都抛出了 ProxyError。
还有三件事会让 URL 形式失效:
- 特殊字符。 像
p@ss/w0rd#1这样的密码直接粘进http://user:p@ss/w0rd#1@host:port,会让 URL 解析器把ss当成主机名。Requests 随后以主机'ss'的NameResolutionError失败,看起来完全不像认证问题。用urllib.parse.quote(value, safe="")分别对用户名和密码做百分号编码。 - 两个键都要设。
proxies字典以目标 URL 的 scheme 为键。只有"http"条目的字典会让https://请求直连,完全不经过代理。把"http"和"https"都设为同一个带认证的 URL。 - 环境变量优先于 Session。 如果环境中设置了
HTTPS_PROXY,Session.proxies会输给它,一个不带凭据的变量就能把正确的 Session 配置变成 407。直接传给请求的proxies=参数仍然优先。最干净的修法是session.trust_env = False,它同时也会禁用对NO_PROXY的继承;环境变量指南 解释了这个设置还控制着什么。
下面的示例把这三点都用上了。它就是实验下载包中的 fix_requests.py 文件,分别用普通密码、含特殊字符的密码和错误密码运行过;前两次打印 200,第三次打印 ProxyError 消息。
import os
from urllib.parse import quote
import requests
# PROXY_URL is scheme://host:port with no credentials inside, for example http://gate.example.net:8080
proxy_url = os.environ["PROXY_URL"]
username = quote(os.environ["PROXY_USERNAME"], safe="")
password = quote(os.environ["PROXY_PASSWORD"], safe="")
target = os.environ.get("TARGET_URL", "https://example.com/")
scheme, host_port = proxy_url.split("://", 1)
authenticated_proxy = f"{scheme}://{username}:{password}@{host_port}"
proxies = {"http": authenticated_proxy, "https": authenticated_proxy}
session = requests.Session()
session.trust_env = False # HTTP_PROXY, HTTPS_PROXY and NO_PROXY can no longer override these proxies
try:
response = session.get(target, proxies=proxies, timeout=(5, 15))
print(response.status_code)
except requests.exceptions.ProxyError as error:
print("proxy rejected the request:", error)Python Requests 代理指南 在此基础上还讲了 Session、SOCKS 和轮换;这里的示例是让 407 消失所需的最小配置。
Python httpx:httpx.ProxyError: 407 Proxy Authentication Required
httpx 为此提供了专门的对象:httpx.Proxy(url, auth=(username, password))。凭据会放进 CONNECT 请求,完全不经过 URL,所以含特殊字符的密码无需编码。httpx.get 或 Client 上的 auth= 参数是网站认证;在记录的运行中,它没有应答代理的质询,抛出了 httpx.ProxyError: 407 Proxy Authentication Required。把凭据写进代理 URL 也可以,前提是做了百分号编码;原样粘贴的话,同一个 p@ss/w0rd#1 密码产生了 httpx.ConnectError: [Errno -3] Temporary failure in name resolution,原因同样是解析器把密码当成了主机名。
import os
import httpx
proxy = httpx.Proxy(
url=os.environ["PROXY_URL"], # scheme://host:port, no credentials inside
auth=(os.environ["PROXY_USERNAME"], os.environ["PROXY_PASSWORD"]),
)
target = os.environ.get("TARGET_URL", "https://example.com/")
with httpx.Client(proxy=proxy, trust_env=False, timeout=15.0) as client:
try:
response = client.get(target)
print(response.status_code)
except httpx.ProxyError as error:
print("proxy rejected the request:", error)httpx.AsyncClient 接受同样的 proxy= 参数;如果你用 mounts 只把部分 URL 模式路由到代理,httpx.HTTPTransport 也接受它。两者都在实验中测试过并返回了 200。若需要完全异步的工作进程,httpx 异步代理指南 基于同一个 Proxy 对象展开。
Python aiohttp:ClientHttpProxyError 407
aiohttp 把两种凭据明确分开:proxy_auth=aiohttp.BasicAuth(username, password) 给代理,auth= 给网站。在记录的运行中,把代理登录信息通过 auth= 传入,抛出了 aiohttp.client_exceptions.ClientHttpProxyError: 407, message='Proxy Authentication Required'。嵌在代理 URL 里的凭据同样被接受。aiohttp 3.14 弃用了 proxy_auth 和 BasicAuth:下面的调用仍然有效,但会打印两条 DeprecationWarning。aiohttp 3.14 代理配置给出了替代写法,以及它无法通过认证的情形。
一个 aiohttp 特有的陷阱:除非你在创建 ClientSession 时传入 trust_env=True,否则它会忽略 HTTP_PROXY 和 HTTPS_PROXY。在默认设置下,没有 proxy= 参数的会话会直接连接,于是一个“在另一台机器上能跑,因为那里设了变量”的脚本会悄悄地不再走代理,而不是以 407 失败。
import asyncio
import os
import aiohttp
async def main() -> None:
proxy_url = os.environ["PROXY_URL"] # scheme://host:port, no credentials inside
proxy_auth = aiohttp.BasicAuth(os.environ["PROXY_USERNAME"], os.environ["PROXY_PASSWORD"])
target = os.environ.get("TARGET_URL", "https://example.com/")
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
try:
async with session.get(target, proxy=proxy_url, proxy_auth=proxy_auth) as response:
print(response.status)
except aiohttp.ClientHttpProxyError as error:
print("proxy rejected the request:", error.status, error.message)
asyncio.run(main())urllib3 与 urllib.request
如果你直接使用 urllib3,ProxyManager 在建立隧道时不会从代理 URL 中读取凭据:对于 https:// 目标,ProxyManager("http://user:pass@host:port") 仍然产生了带 Tunnel connection failed: 407 的 ProxyError。请改为通过 proxy_headers=urllib3.util.make_headers(proxy_basic_auth="user:pass") 传入。request() 的 headers= 参数同样不会用于 CONNECT。标准库的 urllib.request.ProxyHandler 则正好相反:它会解析 http://user:pass@host:port,并在同一次运行中正确应答了质询。
Playwright:Timeout exceeded、状态 407 与 net::ERR_TUNNEL_CONNECTION_FAILED
Playwright 通过 proxy 选项配置代理,可以放在 browserType.launch 或 browser.newContext 上,包含三个字段:server、username 和 password。凭据缺失或错误时,症状取决于 page.goto 在等什么。使用默认的 waitUntil: "load" 以及 "domcontentloaded" 时,永远不会有文档加载完成,所以调用会在整个导航超时耗尽后以 page.goto: Timeout 30000ms exceeded 拒绝,唯一的线索是一个携带 net::ERR_TUNNEL_CONNECTION_FAILED 的 requestfailed 事件。这就是 Playwright 中的代理 407 常常被报告成“代理太慢”的原因。使用 waitUntil: "commit" 时,同一次导航会在几毫秒内完成,返回的响应 status() 为 407,statusText() 为 Proxy Authentication Required,响应头里包含代理的 Proxy-Authenticate。下面的示例先等待 commit,检查状态,然后才等待 domcontentloaded;在记录的运行中,错误密码的情况在一秒之内就以 407 消息失败,而不是等上三十秒。
有两种看起来对、其实不对的放法:
- 把凭据写进
server。{ server: "http://user:pass@host:port" }会被原样接受、不报任何错,随后代理收到的 CONNECT 里根本没有Proxy-Authorization。Chromium 会忽略代理 URL 中的 userinfo 部分。 - 上下文上的
httpCredentials。 在记录的运行中,Chromium 153 确实用httpCredentials应答了代理质询,导航成功了。但这仍然是错误的位置:httpCredentials是网站登录信息,Playwright 会把同一组用户名和密码提供给任何返回 401 的源。请把代理密码放在proxy.username和proxy.password里。
import { chromium } from 'playwright';
const proxy = {
server: process.env.PROXY_URL, // scheme://host:port, no credentials inside
username: process.env.PROXY_USERNAME, // proxy credentials belong here, not in httpCredentials
password: process.env.PROXY_PASSWORD,
};
const target = process.env.TARGET_URL ?? 'https://example.com/';
const browser = await chromium.launch();
try {
const context = await browser.newContext({ proxy });
const page = await context.newPage();
page.on('requestfailed', (request) => console.error('failed:', request.url(), request.failure()?.errorText));
// Wait for 'commit' first: a proxy 407 never reaches 'domcontentloaded', it would surface as a TimeoutError instead.
const response = await page.goto(target, { timeout: 30_000, waitUntil: 'commit' });
if (!response || response.status() === 407) {
throw new Error(`proxy rejected the credentials: ${response?.status()} ${response?.statusText()}`);
}
await page.waitForLoadState('domcontentloaded');
console.log(response.status());
} finally {
await browser.close();
}按上下文单独配置代理时,需要先 chromium.launch({ proxy: { server: 'per-context' } });实验运行确认了以这种方式配置的上下文能够自行完成认证。用于 API 测试的 request.newContext API 接受完全相同的 proxy 对象,带凭据时返回 200,不带时返回 407。密码中的特殊字符在这里无需编码,因为 Playwright 从不用它们拼 URL。有一个限制不是 Playwright 造成的:Chromium 不支持在 SOCKS5 代理上使用用户名和密码,其 代理文档 明确写了这一点,所以需要登录的 socks5:// 网关在 Chromium 中完全无法使用,HTTP 与 SOCKS5 对比指南 可以帮你改选 HTTP 网关。Playwright 代理指南 展示了如何让这份配置按场景隔离。
Puppeteer:net::ERR_INVALID_AUTH_CREDENTIALS
Puppeteer 通过启动参数 --proxy-server=host:port 把代理传给 Chromium,而密码没有对应的参数。不调用 page.authenticate,第一次导航就会以 net::ERR_INVALID_AUTH_CREDENTIALS 拒绝;这是 Chromium 在报告:收到了代理质询,却没有注册任何东西来应答它。密码错误时,page.goto 以 407 状态完成,文档里是 Chromium 自己的错误页,requestfailed 报告 net::ERR_HTTP_RESPONSE_CODE_FAILURE。
记录的运行中确认不起作用的做法:
--proxy-server=http://user:pass@host:port会立即以net::ERR_NO_SUPPORTED_PROXIES失败。page.setExtraHTTPHeaders({ 'Proxy-Authorization': ... })以net::ERR_INVALID_ARGUMENT失败;Chromium 拒绝让页面代码设置这个头。
起作用的做法:在页面第一次导航之前调用 page.authenticate({ username, password })。凭据随后会为该浏览器上下文缓存起来,所以用 browser.newPage() 打开的第二个页面无需自己再调用就能加载,而通过 browser.createBrowserContext() 新建的上下文中的页面则一直以 ERR_INVALID_AUTH_CREDENTIALS 失败,直到它也调用了一次。在一次导航失败之后再调用 page.authenticate 并重试 page.goto 也成功了,这在凭据要稍后才能拿到时很有用。
import puppeteer from 'puppeteer';
const target = process.env.TARGET_URL ?? 'https://example.com/';
const browser = await puppeteer.launch({
args: [`--proxy-server=${process.env.PROXY_URL}`], // scheme://host:port, credentials are not accepted here
});
try {
const page = await browser.newPage();
// Register the proxy credentials before the first navigation; every new browser context needs its own call.
await page.authenticate({ username: process.env.PROXY_USERNAME, password: process.env.PROXY_PASSWORD });
const response = await page.goto(target, { timeout: 30_000, waitUntil: 'domcontentloaded' });
if (!response || response.status() === 407) {
throw new Error(`proxy rejected the credentials: ${response?.status()}`);
}
console.log(response.status());
} finally {
await browser.close();
}只需要 HTTP 而不需要浏览器的 Node.js 代码,不应该为此启动 Chromium;Node.js fetch 代理指南 把同一组凭据放到了 Undici 的 ProxyAgent 上。
发送可复现、已脱敏的报告
保留客户端版本、UTC 时间戳、网关主机和端口、目标站点主机名、认证方式以及状态码或异常类。说明最小化的 curl 复现是否同样失败。这些细节能让支持团队在不接收你密码的情况下找到那次尝试。
从日志中移除令牌、带凭据的 URL、Cookie 和授权请求头。诊断持续出现的 407 时停止自动重试:一个重试循环会把一次 407 变成一连串失败登录,丢失一个响应,创建了两个任务 解释了盲目重试在其他方面也有害的原因。修正之后,先重新运行一个请求,再运行最小的应用场景,然后才恢复正常并发。
如需在兼容 MCP 的编程智能体中获得帮助,Proxy Toolkit MCP 提供错误诊断和受支持的配置模板。描述客户端和脱敏后的错误即可,不要发送代理凭据或完整日志;任何建议的改动都要用上面的有界复现来验证。
- 把代理认证与网站认证分开识别。
- 在第二个客户端中用相同设置复现。
- 分享脱敏后的诊断信息,而不是凭据或原始跟踪记录。
复现记录下来的运行
上面的客户端症状和示例都是用 下载目录 里的实验环境记录的:一个运行在 127.0.0.1 上、要求 Basic 认证并转发到 https://example.com/ 的假代理,能产生每一条 Python 错误原文的 symptoms.py 脚本,Playwright、Puppeteer 和 curl 的用例,以及分别用普通密码、含特殊字符的密码和错误密码运行的五份 fix_* 示例。记录下来的 results.json 列出了所用版本:Python 3.14、requests 2.34、httpx 0.28、aiohttp 3.14、urllib3 2.8、Node.js 24、Playwright 1.63 与 Chromium 153、Puppeteer 25 和 curl 8.18。curl 8.15 至 8.17 和 8.22.0 的消息来自随 CONNECT tunnel failed, response 403 一同发布的环回实验。新版本可能改变错误原文;但机制不会变:代理凭据放在代理选项里,别处都不放。
参考来源与延伸阅读
本指南参考的技术资料。请以你所安装版本的文档以及代理服务商支持的配置为准。
- RFC 9110: 407 Proxy Authentication Required
- Everything curl: proxy authentication
- Playwright: proxy credentials
- Requests documentation: proxies
- HTTPX documentation: proxies
- aiohttp documentation: proxy support
- urllib3 documentation: proxies
- Puppeteer API: page.authenticate
- Chromium proxy support: SOCKS authentication