Puppeteer 运行一段时间后报 `net::ERR_BLOCKED_BY_CLIENT`:一次 Chromium HTTPS 自动升级问题的完整排查

Puppeteer 运行一段时间后报 net::ERR_BLOCKED_BY_CLIENT:一次 Chromium HTTPS 自动升级问题的完整排查

本文记录一次基于 Puppeteer + Headless Chromium 的服务端打印故障排查过程。为便于公开分享,文中的服务名、IP 地址、接口路径、日志时间、容器信息和项目路径均已脱敏或泛化。

摘要

某个基于 Puppeteer 的服务端打印服务,在持续运行一段时间后开始稳定报错:

text 复制代码
Error: net::ERR_BLOCKED_BY_CLIENT

奇怪的是:

  • 目标 HTTP 页面可以通过 curl 正常访问;
  • 打印服务进程和 Chromium 进程都没有退出;
  • 立即重试仍然失败;
  • 重启打印服务后又能暂时恢复;
  • 运行一段时间后问题可能再次出现。

通过源码执行顺序、Chrome DevTools Protocol(CDP)网络事件、HTTP/HTTPS 对照测试,以及独立 Chromium 启动参数实验,最终确认:

Chromium 将原本的 HTTP 主文档请求自动升级成了 HTTPS,而目标内网服务没有开放 HTTPS 端口。无头浏览器无法处理安全提示页面,最终以 net::ERR_BLOCKED_BY_CLIENT 终止导航。

临时修复是在 Puppeteer 启动参数中禁用相关 HTTPS 自动升级特性;长期方案则是为目标服务提供有效的 HTTPS 入口。


一、问题背景

系统中有一个独立的 Node.js 打印服务,主要流程如下:

  1. Puppeteer 创建页面;
  2. 访问系统登录页;
  3. sessionStoragelocalStorage 写入认证信息;
  4. 跳转到实际打印页面;
  5. 等待页面渲染完成并生成 PDF。

服务为了降低浏览器启动成本,会在进程生命周期内复用同一个 Chromium 实例。

运行一段时间后,打印请求开始失败,日志类似:

text 复制代码
打印失败,原因:Error: net::ERR_BLOCKED_BY_CLIENT
at http://10.x.x.x/#/login

重启打印服务后,打印功能会立即恢复。

这类现象很容易被初步判断为:

  • token 过期;
  • 页面接口鉴权失败;
  • 内网服务不通;
  • Puppeteer 请求拦截配置异常;
  • 浏览器扩展拦截;
  • Node.js 内存不足;
  • Chromium 长时间运行后状态异常。

但仅凭错误名称无法直接确认根因,需要进一步构造证据链。


二、先确认错误发生在哪个阶段

2.1 检查打印代码的执行顺序

打印逻辑的关键顺序可简化为:

ts 复制代码
await page.goto('http://10.x.x.x/#/login')

await page.evaluate((authInfo) => {
  sessionStorage.setItem('token', authInfo.token)
  localStorage.setItem('clientId', authInfo.clientId)
}, authInfo)

await page.goto('http://10.x.x.x/#/print-page')

实际错误出现在第一次 page.goto(),也就是访问登录页时。

此时:

  • token 尚未写入;
  • 业务打印页尚未打开;
  • 页面业务接口也尚未开始执行。

因此可以排除:

  • 旧 token 导致的认证失败;
  • token 过期;
  • 打印页面接口返回 401 或 403;
  • 打印页面内部 JavaScript 业务异常。

这一步非常重要。排查 Puppeteer 导航错误时,应先明确错误发生在:

  • 浏览器启动阶段;
  • 主文档导航阶段;
  • 页面资源加载阶段;
  • 页面脚本执行阶段;
  • 业务接口调用阶段;
  • PDF 生成阶段。

不同阶段的排查方向完全不同。

2.2 HTTP 200 不代表打印成功

打印服务的访问日志中,接口可能显示:

text 复制代码
POST /print/batch 200

但响应体实际为:

json 复制代码
{
  "success": false
}

这说明 HTTP 200 只代表:

打印服务正常接收并处理了请求,同时正常返回了一个"打印失败"的业务结果。

它并不代表 PDF 已成功生成。

生产系统中建议让调用方同时检查:

  • HTTP 状态码;
  • 业务状态字段;
  • 错误码;
  • 错误消息;
  • 生成文件是否真实存在。

三、检查是否是业务代码主动拦截请求

ERR_BLOCKED_BY_CLIENT 经常与请求拦截有关,因此先检查项目代码中是否存在:

ts 复制代码
page.setRequestInterception(true)
request.abort()

或者通过 CDP 设置了:

text 复制代码
Network.setBlockedURLs
Fetch.enable

本次检查未发现打印服务主动阻断登录页主文档请求的代码。

浏览器启动参数中还包含:

text 复制代码
--disable-extensions

因此浏览器扩展拦截的可能性也较低。

需要注意的是:

没找到主动拦截代码,只能说明"业务代码直接拦截"的证据不足,不能仅凭这一点断定 Chromium 内部没有发生其他导航阻断。


四、确认 Chromium 是否仍然存活

打印失败时,先不要急着重启服务,否则会破坏现场。

可以在容器内检查 Chromium 调试端口:

bash 复制代码
curl http://127.0.0.1:9222/json/version

返回结果类似:

json 复制代码
{
  "Browser": "Chrome/120.x",
  "Protocol-Version": "1.3"
}

同时也可以检查当前 target:

bash 复制代码
curl http://127.0.0.1:9222/json/list

如果可以正常返回,通常可以说明:

  • Node.js 打印服务进程仍存活;
  • Chromium 主进程仍存活;
  • CDP 调试端口仍可用;
  • 故障不是简单的浏览器进程崩溃。

本次现场中,Chromium 与调试端口均正常存活。


五、使用 CDP 捕获真正的网络失败事件

Puppeteer 抛出的异常有时只展示最终错误,CDP 的 Network.loadingFailed 可以提供更接近浏览器内部的证据。

示例监听代码:

ts 复制代码
const client = await page.target().createCDPSession()

await client.send('Network.enable')

client.on('Network.loadingFailed', (event) => {
  console.log(JSON.stringify(event, null, 2))
})

await page.goto('http://10.x.x.x/#/login')

现场捕获到:

json 复制代码
{
  "type": "Document",
  "errorText": "net::ERR_BLOCKED_BY_CLIENT",
  "canceled": false,
  "blockedReason": "other"
}

之后又出现:

json 复制代码
{
  "type": "Document",
  "errorText": "net::ERR_ABORTED",
  "canceled": true
}

5.1 这组事件说明了什么

type: Document 表明失败的是主文档导航,而不是图片、字体、接口请求或其他子资源。

因此可以进一步判断:

  • 不是页面加载完成后的业务接口失败;
  • 不是某张图片或静态资源被拦截;
  • 不是目标服务返回了普通的 401、403 或 500;
  • 阻断发生在 Chromium 客户端侧的主导航过程中。

后续的 ERR_ABORTED 是打印代码捕获异常后关闭页面产生的次生事件,不能把它当作首因。

5.2 blockedReason: other 不等于 DevTools Request Blocking

如果是常规 DevTools Network 面板中的 Request Blocking,可能会看到更明确的阻断原因。

本次返回:

text 复制代码
blockedReason: other

因此不能直接得出"有人在 DevTools 中配置了 Request Blocking"的结论。

此时更合理的判断是:

Chromium 客户端内部发生了导航阻断,但仅凭该字段还无法确认具体是哪一种内部机制。


六、逐项排除常见方向

排查方向 当前结论 主要依据
token 失效 排除 登录页主导航在写入 token 前已失败
页面业务接口鉴权失败 排除 失败类型为 Document 主文档导航
普通网络超时 优先级较低 错误不是 DNS、连接超时或拒绝连接
Node.js 堆内存耗尽 暂无证据 进程内存正常,错误类型也不符合 OOM
浏览器扩展拦截 基本排除 Chromium 使用 --disable-extensions
Service Worker 拦截 排除 CDP target 中未发现相关 Service Worker
外部 CDP 客户端拦截 经连接反查后未发现 活动 CDP WebSocket 属于打印服务自身

6.1 检查 CDP 连接来源

如果怀疑有其他程序连接 Chromium 调试端口,可以检查:

bash 复制代码
grep -i ':2406' /proc/net/tcp

其中 2406 是十六进制的 9222。

常见状态:

  • 0A:LISTEN;
  • 01:ESTABLISHED;
  • 06:TIME_WAIT。

如果发现活动连接,可根据 socket inode 继续反查对应 PID 和进程命令行。

本次最终确认,活动连接来自打印服务自身建立的 Puppeteer CDP WebSocket,而不是外部调试客户端。


七、决定性实验:请求是否被升级成 HTTPS

前面的证据已经确认:主文档被 Chromium 客户端阻断,但还不知道具体原因。

随后通过 CDP 观察实际导航地址,发现:

text 复制代码
原始地址:http://10.x.x.x/#/login
实际尝试:https://10.x.x.x/

也就是说,Chromium 将 HTTP 自动升级成了 HTTPS。

接下来做对照验证。

7.1 验证 HTTP 服务本身是否正常

bash 复制代码
curl -I http://10.x.x.x/

返回:

text 复制代码
HTTP/1.1 200 OK

并且响应中没有:

text 复制代码
Location: https://...

说明服务端 Nginx 并没有主动把 HTTP 重定向到 HTTPS。

7.2 验证目标是否提供 HTTPS

bash 复制代码
curl -k -I https://10.x.x.x/

结果为 443 端口无法连接。

由此可以确认:

  • HTTP 服务正常;
  • 服务端没有做 HTTPS 重定向;
  • HTTPS 服务不可用;
  • HTTP 到 HTTPS 的变化来自 Chromium 客户端。

7.3 禁用 HttpsUpgrades 做单变量对照

使用一个独立的临时 Chromium 实例,并增加:

text 复制代码
--disable-features=HttpsUpgrades

再次访问同一个地址,结果请求保持为 HTTP,并成功返回:

text 复制代码
SUCCESS http://10.x.x.x/#/ 200

至此,直接故障链路得到确认:

text 复制代码
Puppeteer 访问 HTTP 登录页
        ↓
Chromium 将主文档自动升级为 HTTPS
        ↓
目标内网服务没有开放 443
        ↓
无头浏览器无法继续处理安全提示
        ↓
Chromium 以 ERR_BLOCKED_BY_CLIENT 终止导航
        ↓
认证信息尚未写入,打印任务直接失败

八、为什么服务刚启动正常,运行一段时间后才失败

这一点是本次问题最容易让人困惑的地方。

8.1 浏览器实例和 profile 被长期复用

打印服务长期复用同一个 Chromium 实例,并使用类似以下参数:

text 复制代码
--user-data-dir=/tmp/puppeteer-profile-xxxx

虽然该目录是临时目录,但只要 Chromium 进程没有重建,这个 profile 在服务生命周期内就会一直存在。

浏览器可能在 profile 中维护:

  • HTTPS 升级状态;
  • 站点参与度;
  • 导航历史相关状态;
  • 安全模式配置;
  • 实验性特性的启用状态。

服务重启后,旧 Chromium 被关闭,新 profile 被创建,因此相关状态会被重置,打印也会暂时恢复。

8.2 高置信度解释:HTTPS-First Mode 启发式触发

结合以下现象:

  • HTTP 地址实际被升级为 HTTPS;
  • 禁用 HttpsUpgrades 后立即恢复;
  • 故障绑定在长期运行的 Chromium/profile 上;
  • 重建浏览器 profile 后可恢复;
  • 服务运行时间与故障出现存在明显相关性;

高置信度判断是:Chromium 的 HTTPS-First Mode 或相关 HTTPS 自动升级机制,在长期运行的 profile 中被启用或生效。

需要强调:

"HTTP 被 Chromium 自动升级"已经通过对照实验确认;至于具体是哪条启发式规则、Field Trial 或 profile 条件触发,仍可能受到 Chromium 版本和实验配置影响。

因此在技术文档中,最好把它分成两层:

  • 已确认的直接原因:Chromium 把 HTTP 导航升级成 HTTPS;
  • 高置信度的触发解释:长期复用的 profile 触发了 HTTPS-First Mode 或相关升级策略。

不要把后者写成所有 Chromium 版本都完全一致的固定规则。


九、修复方案

9.1 临时直接修复:禁用相关 HTTPS 自动升级特性

在 Puppeteer 启动参数中加入:

ts 复制代码
const browser = await puppeteer.launch({
  headless: 'new',
  args: [
    '--disable-features=HttpsUpgrades,HttpsFirstModeV2ForEngagedSites,HttpsFirstModeV2ForTypicallySecureUsers'
  ]
})

该方案适用于:

  • 目标是受控内网服务;
  • 服务当前只支持 HTTP;
  • 暂时无法改造 HTTPS;
  • 打印浏览器只用于访问受信任的内部页面。

需要注意:关闭浏览器安全升级能力属于兼容性方案,不应作为互联网公开页面的通用配置。

9.2 长期推荐方案:为目标服务提供 HTTPS

更理想的方案是:

  • 为内网页面配置 HTTPS;
  • 开放并正确代理 443;
  • 配置可信证书;
  • 让 Puppeteer 直接访问 HTTPS 地址;
  • 避免依赖关闭 Chromium 安全特性。

从长期维护和安全角度看,这比永久禁用 HTTPS 升级更合理。

9.3 增加浏览器自愈机制

即使完成根因修复,也建议增加有限的自愈能力:

ts 复制代码
async function recreateBrowser(): Promise<void> {
  if (browser) {
    await browser.close().catch(() => undefined)
  }

  browser = await createBrowser()
}

当出现明确的浏览器状态类错误时,可以:

  1. 记录结构化错误;
  2. 关闭当前页面;
  3. 重建 Chromium;
  4. 仅重试一次;
  5. 如果仍失败,则返回明确错误。

不要无限重试,否则可能掩盖真实问题并放大资源消耗。

9.4 使用隔离的 BrowserContext

多个打印任务共享同一个浏览器时,建议至少为每个任务创建独立上下文:

ts 复制代码
const context = await browser.createBrowserContext()
const page = await context.newPage()

try {
  // 执行打印
} finally {
  await context.close()
}

这样可以减少以下状态在任务之间互相影响:

  • Cookie;
  • LocalStorage;
  • SessionStorage;
  • 缓存;
  • 权限;
  • 页面级会话状态。

但需要说明:如果问题绑定在整个 Chromium profile 或浏览器级特性上,仅使用 BrowserContext 不一定能解决,仍可能需要重建 browser。


十、构建可重复的回归测试

这类问题如果只能"运行十几天等待复现",就很难验证修复是否有效。

可以在独立临时 profile 的 Default/Preferences 中预置:

json 复制代码
{
  "https_only_mode_enabled": true
}

然后使用测试脚本分别运行故障模式和修复模式。

示例:

bash 复制代码
# 预期:HTTP 被升级成 HTTPS,出现 ERR_BLOCKED_BY_CLIENT
node scripts/repro-https-first-mode.js broken

# 预期:保持 HTTP,并成功返回 200
node scripts/repro-https-first-mode.js fixed

建议测试脚本满足以下要求:

  • 每次创建独立临时 profile;
  • 测试完成后自动清理;
  • 不读取生产浏览器 profile;
  • 不修改正在运行的打印服务;
  • brokenfixed 使用同一个目标地址;
  • 两组测试只改变 Chromium 启动参数。

这样才能形成有效的单变量对照。


十一、生产环境中容易忽略的安全问题

本次排查还暴露出一些与根因无关、但值得单独治理的问题。

11.1 日志中不要输出完整 token

打印服务可能会记录完整请求体,其中包含 token、用户信息或其他认证数据。

建议:

  • 对 token 统一脱敏;
  • 不记录完整 Authorization Header;
  • 限制生产日志级别;
  • 设置合理的日志保留周期;
  • 检查历史日志是否包含敏感信息。

11.2 不要在源码或普通配置中保存明文凭据

对象存储、数据库或第三方服务的访问凭据应迁移到:

  • 环境变量;
  • Kubernetes Secret;
  • 云厂商密钥管理服务;
  • 专用配置中心的加密配置。

11.3 不要无鉴权暴露 CDP 调试能力

Chrome DevTools Protocol 可以读取和控制浏览器页面。

生产环境中:

  • 调试端口只监听本机;
  • 不应直接暴露 9222;
  • 调试代理默认关闭;
  • 如确需开放,必须增加鉴权、来源限制和审计;
  • 避免公开页面枚举接口和 WebSocket 代理。

十二、推荐的通用排查流程

遇到 Puppeteer 的 ERR_BLOCKED_BY_CLIENT 时,可以按以下顺序排查。

第一步:确定失败资源类型

通过 CDP 查看:

json 复制代码
{
  "type": "Document"
}

区分失败的是:

  • 主文档;
  • Fetch/XHR;
  • 图片;
  • 脚本;
  • 字体;
  • 其他静态资源。

第二步:确认实际请求 URL

不要只看传给 page.goto() 的地址,还要确认 Chromium 最终访问的是 HTTP 还是 HTTPS。

第三步:做客户端与服务端对照

bash 复制代码
curl -I http://目标地址/
curl -k -I https://目标地址/

确认:

  • HTTP 是否正常;
  • 服务端是否返回重定向;
  • HTTPS 是否可用;
  • 问题发生在服务端还是浏览器客户端。

第四步:检查请求拦截

重点搜索:

text 复制代码
setRequestInterception
request.abort
Network.setBlockedURLs
Fetch.enable

同时检查是否存在外部 CDP 客户端。

第五步:使用独立 profile 做单变量实验

尝试:

  • 新建无痕上下文;
  • 新建 browser;
  • 新建临时 user-data-dir;
  • 禁用可疑 feature;
  • 使用相同地址做对照。

第六步:区分直接原因与触发原因

例如本次问题中:

  • 直接原因:HTTP 被升级为 HTTPS,443 不可用;
  • 触发原因:长期运行 profile 中 HTTPS 自动升级策略生效;
  • 表面恢复方式:重启打印服务;
  • 永久修复:支持 HTTPS 或显式调整 Chromium 特性。

十三、最终结论

本次故障不是 token、业务接口、普通网络超时或 Node.js 内存问题。

已经通过 CDP 和独立 Chromium 对照实验确认:

Puppeteer 打开的 HTTP 主文档被 Chromium 自动升级成 HTTPS,而目标服务没有提供 HTTPS。无头 Chromium 最终以 net::ERR_BLOCKED_BY_CLIENT 阻断导航。

服务重启后恢复,是因为 Chromium 实例和临时 profile 被重建,相关浏览器状态被清除;长期复用同一 browser/profile 后,问题可能再次出现。

推荐处理优先级:

  1. 长期方案:为目标页面提供可用的 HTTPS;
  2. 兼容方案:在受控内网打印场景中禁用相关 HTTPS 自动升级 feature;
  3. 稳定性方案:增加 browser 重建、有限重试和结构化错误日志;
  4. 隔离方案:每个任务使用独立 BrowserContext;
  5. 安全方案:收口 CDP 调试入口并对日志、凭据进行脱敏。

这次排查最值得复用的经验是:

遇到浏览器导航错误时,不要只看 Puppeteer 最终抛出的异常。应结合代码执行阶段、CDP 网络事件、实际导航 URL 和单变量对照实验,逐层缩小问题范围。


参考标签

Puppeteer Chromium Node.js ERR_BLOCKED_BY_CLIENT HTTPS-First Mode CDP 服务端打印 故障排查

相关推荐
March.s3 小时前
IP 存储核心 iSCSI:原理拆解 + 无报错实战指南
网络·网络协议·tcp/ip
后除5 小时前
Debian 安装与使用 Certbot
后端·nginx·https
godwaskillde5 小时前
Java 11 HttpClient如何配置HTTP代理?连接测试与异常处理示例
java·开发语言·http
2501_915921436 小时前
抓包鹰抓取系统 App 的流量,从底层读明文
网络协议·计算机网络·网络安全·adb·https·udp
若汝棋茗6 小时前
UDP 调试难复现?先把目标端点、广播和消息记录管起来
网络·网络协议·udp
小白说大模型8 小时前
从0开始学计算机网络:HTTP 协议的进化史
人工智能·网络协议·计算机网络·http
esabby8 小时前
253个原生IP + 40M独享回国带宽:香港站群服务器的硬核拆解
服务器·网络协议·tcp/ip
无糖可乐没有灵魂9 小时前
Security ❀ Https TLS抓包操作与解密配置
网络协议·http·https
treesforest9 小时前
随意装软件也会被恶意IP入侵电脑?
网络·网络协议·tcp/ip·网络安全·ip属地·查ip归属地