故障排查阅读约 11 分钟

不靠猜测修复代理错误 407

按可重复的顺序诊断 Proxy Authentication Required:网关、认证方式、凭据编码、账户范围和客户端配置。

本页内容

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.34requests.exceptions.ProxyError,附带 Tunnel connection failed: 407 Proxy Authentication RequiredResponse,status_code == 407
httpx 0.28httpx.ProxyError: 407 Proxy Authentication RequiredResponse,status_code == 407
aiohttp 3.14aiohttp.ClientHttpProxyError: 407, message='Proxy Authentication Required'响应的 status == 407
urllib3 2.8urllib3.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 25page.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} 为 407http=407
curl 8.18.0退出码 7,curl: (7) CONNECT tunnel failed, response 407,%{http_connect} 为 407http=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,哪怕密码是对的。

sh
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 消息。

python
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,原因同样是解析器把密码当成了主机名。

python
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 失败。

python
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 里。
javascript
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 也成功了,这在凭据要稍后才能拿到时很有用。

javascript
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 一同发布的环回实验。新版本可能改变错误原文;但机制不会变:代理凭据放在代理选项里,别处都不放。

参考来源与延伸阅读

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