先看一条很容易误判的报错:

ProxyError: Tunnel connection failed: 403 Forbidden

看到 403,很多人第一反应是换代理 IP,或者给请求补 User-Agent。但这条日志里更重要的是 Tunnel connection failed:HTTPS 隧道还没建起来,目标页面的业务请求很可能根本没有发出去。继续修改目标站 Header,方向基本就错了。

我通常先把日志分成三类:已经拿到普通 403 响应;CONNECT 阶段返回 Tunnel 403;只知道超时,却不知道卡在哪一步。它们看起来相似,负责处理的人却可能完全不同。

另外,Max retries exceeded 往往只是最外层异常。排查时要继续往里找 ProxyErrorConnectTimeoutReadTimeout 或更具体的错误信息。

文章目录

  1. 先别急着换 IP:这两个 403 不是一回事
  2. 超时发生在哪一步,决定你该查哪里
  3. 我通常先跑这几组对照
  4. 日志至少要回答这几个问题
  5. 定位以后,该换的可能不是 IP

一、先别急着换 IP:这两个 403 不是一回事

判断 403 时,我先看客户端有没有拿到正常的响应对象,再看这个响应来自哪一段。普通 403 只能证明某个 HTTP 服务明确拒绝了请求;这个服务可能是目标站、CDN/WAF,也可能是普通 HTTP 代理、企业中间代理或拦截型网关。只凭状态码,还不能断定请求已经到达目标侧。

日志里看到什么 目前能确认什么 先查哪里
正常响应对象,状态码 403 已有某个 HTTP 服务接收并拒绝请求;是否到达目标侧,要结合最终 URL、响应头和响应体判断 响应来源、重定向、账号权限、请求环境、访问频率和出口差异
ProxyError 内含 Tunnel connection failed: 403 代理在 CONNECT 阶段拒绝建立 HTTPS 隧道,目标业务请求尚未穿过隧道 代理入口、端口、认证、白名单、地区参数、账户与目标端口权限
ConnectTimeoutReadTimeout 或浏览器超时 某个等待窗口结束,暂时不能据此确定责任方 异常指向的主机、最后完成的阶段和各阶段耗时
代理错误诊断决策树,先判断是否拿到HTTP响应,再区分普通403、Tunnel 403和不同阶段的超时
先看有没有拿到 HTTP 响应,再判断 HTTPS 隧道是否建立;这两步能把普通 403、Tunnel 403 和阶段性超时分开。

普通 403 到手后,先保存最终 URL、重定向链、响应头和响应体。页面标题、正文特征、ServerVia 和请求 ID 都能提供线索,但不要单靠其中一个字段下结论。最有用的是做对照:同一请求换出口后是否变化,换客户端后是否变化,直连和代理返回的页面是否相同。

Tunnel 403 的检查顺序更直接。我会先核对入口与协议,再看用户名、密码或 Token 是否完整,客户端公网 IP 是否仍在白名单,国家、城市与会话参数是否符合格式,最后确认套餐、余额、并发和目标端口权限。缺少代理凭据时,标准状态通常是 407;但具体服务也可能用 403 表示当前凭据无权使用某个区域或资源。

RFC 9110 对 403 的定义说明服务器理解请求但拒绝执行;同一规范的 CONNECT 说明则明确:代理返回成功状态后才进入隧道模式。这就是普通 403 与 Tunnel 403 最关键的区别。

二、超时发生在哪一步,决定你该查哪里

Timeout 本身不提供责任方。先看日志里的主机名和最后一个成功事件,比单纯把超时时间从 30 秒改到 60 秒更有用。

  • ConnectTimeout 指向代理主机:先测代理入口和端口,检查本地网络、DNS、防火墙以及入口是否可用。配置代理后,客户端连接阶段面对的远端主机通常先是代理入口。
  • CONNECT 已成功,随后 TLS 或首字节超时:固定目标,换出口节点和地区对照。如果只有某个地区持续变慢,更像是区域路由、出口链路或目标端口问题。
  • 页面已经打开,却等不到 load、元素或业务数据:先看主文档和异步接口是否返回。选择器变了、页面版本不同,或者等待条件不合适,都不会因为增加 IP 数量而消失。

Python Requests 的超时说明把连接超时和读取超时分开。实际日志也应至少把代理连接、隧道、首字节和总耗时拆开,否则所有慢请求最后只剩一个没有方向的 TimeoutError

三、我通常先跑这几组对照

如果现有日志看不出请求停在哪里,我不会立即扩大重试,而是先把变量减到最少。

先用当前代理访问供应商提供的诊断地址。这里就出现 403、407 或连接超时,问题还在接入层,不必继续修改目标页面的 Cookie 和 Header。

诊断地址正常,再访问一个团队自己可控的稳定页面。可控页面能打开、业务目标失败,范围就缩小到了目标策略、业务请求或特定路由。

然后固定目标,一次只换一个变量。先比较当前出口和已知正常出口,再比较 HTTP 客户端和浏览器客户端。若一次同时更换 IP、Header、Cookie 和并发,即使请求恢复,也很难知道究竟是哪项起了作用。

仍然拿不准时,我会做一个很小的“目标 × 出口”对照:两个稳定目标、一个业务目标,再配两个出口。一个出口对所有目标都失败,先查节点或入口;多个出口只对同一目标返回普通 403,先查目标权限和请求环境;只有某个客户端出现 Tunnel 403,则回到该客户端的代理 URL、协议、认证透传和环境变量。

恢复连接后还要检查返回内容。状态码变成 200,但页面是拦截页、空页或错误地区版本,任务仍没有真正恢复;可以继续使用状态码、页面类型和业务字段分层验收的方法检查结果。

四、日志至少要回答这几个问题

这次请求用了哪个客户端和代理入口?有没有进入 CONNECT?状态码来自代理还是目标?最后成功的是哪一步?把这些问题直接写进请求级日志,下次就不用重新猜。

{
  "request_id": "req_7f2...",
  "target_host": "target.example",
  "client_type": "requests",
  "proxy_gateway": "gateway-region-a",
  "assigned_exit_id": null,
  "exit_ip_hash": null,
  "stage": "connect_tunnel",
  "proxy_status": 403,
  "target_status": null,
  "connect_ms": 420,
  "tunnel_ms": 180,
  "ttfb_ms": null,
  "total_ms": 650,
  "exception_chain": [
    "ProxyError",
    "MaxRetryError",
    "OSError"
  ],
  "inner_error": "Tunnel connection failed: 403",
  "diagnosed_cause": null,
  "retry_count": 0
}

这是一条 Tunnel 403 示例,所以 target_statusexit_ip_hash 都为空:隧道尚未建立时,客户端不一定已经知道实际出口 IP。inner_error 只是最内层错误文字,不等于根因;等认证、白名单或区域权限验证完成后,再把结论写进 diagnosed_cause

日志也不要只保存最外层的 Max retries exceeded。异常类型链和最内层错误缺一不可,否则后续还是无法区分代理认证、连接超时和读取超时。密码、Token、Cookie 与完整个人数据则不应写入日志。

重试策略同样要看阶段。普通目标 403 先查权限、会话和频率;Tunnel 403 或 407 先修入口与认证;连接与读取超时只对适合重试的请求设置上限和退避;浏览器等待超时先查主文档、异步接口和选择器。HTTP 库、代理 SDK、浏览器任务和业务队列若各自重试,单条失败任务很容易被放大成大量无效执行。

五、定位以后,该换的可能不是 IP

  • 入口或部分节点不稳定:先修正认证与路由,再比较节点健康、地区覆盖和失败节点剔除能力。合法数据采集需要大量轮换出口时,可以用真实目标小规模验证 IPWeb 动态住宅代理,分别记录 Tunnel 成功率、目标响应率和有效数据率。
  • 普通 HTTP 客户端拿不到动态页面内容:先检查主 HTML、异步接口和等待条件。问题确认在浏览器执行与结构化输出层后,再评估 网页抓取 API
  • 目标权限、账号或条款明确拒绝:使用官方授权、降低频率或停止任务。代理不能替代访问许可。
  • 问题在浏览器或下游队列:修正选择器、资源加载、超时和任务容量。继续增加 IP 不会修复应用层瓶颈。

最后的故障单不需要写得很长,但要说清楚:哪个客户端通过哪个入口访问哪个目标,请求停在哪一步,状态码由谁返回,哪组对照改变了结果。能回答这几个问题,报错才算从“现象”变成了可处理的任务。

参考资料