unable to get local issuer certificate — 第一次就失败、而且永远不重试的那个报错
- 秒失败、每次都失败=问题在本地。除证书校验外几乎一切失败都会被自动退避重试,能立刻抛到你脸上的恰恰是不重试的那类。
- 导出你所在网络的 CA 证书,用
NODE_EXTRA_CA_CERTS指向它,然后重启客户端。 - 不要设
NODE_TLS_REJECT_UNAUTHORIZED=0——它关掉的是整个进程的 TLS 校验,把一个配置问题换成一个安全问题。
客户端和端点之间出的岔子,绝大多数都会被自动重试掉——服务器错误、过载、请求超时、断连、临时限流,都会指数退避重试最多 10 次,然后才可能让你看见。TLS 证书验证失败是极少数一次都不重试的,它在第一次尝试就把错误抛出来。这件事把判断方向整个反过来了:一个瞬间就出现、而且每次都出现的报错,不是「这家服务不稳」的证据,恰恰是「问题在本地、可复现、归你修」的证据。
你看到的报错
Error: unable to get local issuer certificate
Error: self-signed certificate in certificate chain
{ code: 'SELF_SIGNED_CERT_IN_CHAIN' }
{ code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }
SSL certificate verification failed
SSL certificate error (...)
这四段说的是同一件事,只是站在不同高度描述。你的运行时拿到服务器出示的证书,想沿着证书链一路走到一个它本来就信任的根,走不通。报错信息告诉你的是在哪一步断的。
| 错误码 | 你会看到的信息 | 链在哪里断 |
|---|---|---|
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | unable to get local issuer certificate | 出示的证书的签发者不在你的信任库里 |
SELF_SIGNED_CERT_IN_CHAIN | self-signed certificate in certificate chain | 链里有一张自己签自己的证书——通常是某个代理的私有根 |
UNABLE_TO_VERIFY_LEAF_SIGNATURE | unable to verify the first certificate | 服务器只发了叶证书,没发中间证书 |
CERT_HAS_EXPIRED | certificate has expired | 问题在日期,不在信任 |
self signed certificate in certificate chain——没有连字符。同一个故障。两种拼法都要搜。
这些都不代表服务器挂了。真挂了的服务器,根本走不到「出示证书」这一步。
让这个报错显得比实际严重的,是重试的不对称
| 故障 | 重试预算 | 你实际的体感 |
|---|---|---|
| 服务器错误、过载、在任何响应开始流式返回之前的请求超时 | 指数退避最多 10 次 | 卡一下,然后通常就成功了——你往往根本不知道发生过 |
| 断连,或你的机器休眠导致的断连 | 同一份预算 | 同上 |
临时 429 限流 | 同一份预算 | 同上 |
| 响应流卡住、且还没有任何内容到达 | 额外重发一次,不占那 10 次预算 | 同上 |
| TLS 证书验证失败 | 没有——第一次尝试就报出来 | 瞬间、每次、不卡顿 |
| TLS 握手超时 | 照常重试——它是瞬时故障,不是验证失败 | 卡一下,然后通常就成功了 |
把这张表当成一个筛子来读:有重试预算的故障,必须连续失败 10 次、而且每次间隔越来越长,才轮得到你看见。没有预算的,第一次就到你眼前。所以出现得越快的报错,越系统性地偏向「没人给它重试」的那一类。这意味着很大一部分被人描述成「这服务时好时坏」的故障,性质其实是反的:一个稳定的、本地的、可复现的配置问题,只是它和终端之间恰好没有任何缓冲。
落到实用一句话:30 秒才蹦出来的报错,和瞬间蹦出来的报错,不是同一类问题,哪怕文案长得像。出错的快慢本身就是诊断信息。在动手改任何东西之前先用它。
两条必须说清楚的限定。证书失败并不是一直都免于重试的——早期版本会让它先走完整个重试预算,所以在旧版本上,证书问题看起来也是又慢又飘的。另外,「TLS 报错」不等于「不重试」:握手超时是瞬时故障,照样重试。把出错快慢当信号用之前,先在你自己正在跑的那个客户端版本上确认一次实际行为。
到底是哪一层坏了
| 层 | 发生了什么 | 怎么认出来 | 谁能修 |
|---|---|---|---|
| 你的运行时信任库 | CA 装在操作系统里了,但运行时读不到系统信任库 | npm 安装 + 较老的 Node;同一个客户端换原生安装就正常 | 你 |
| TLS 检查型代理 | 有东西把 TLS 拆开,再用私有根重新签名 | 出示的签发者不是公共 CA | 你(把 CA 加进去)或公司 IT |
| CA bundle 根本没进到进程里 | NODE_EXTRA_CA_CERTS 设了,但文件没被读进去 | 状态界面显示了路径,调试日志里却没有「已追加」那行 | 你 |
| 缺中间证书 | 服务器只发叶证书 | UNABLE_TO_VERIFY_LEAF_SIGNATURE,且别人在别的机器上一样报错 | 端点运营方 |
| 端点自己的证书过期 | 问题在日期,不在信任 | 签发者确实是公共 CA,而 notAfter 已经过去 | 端点运营方 |
多数同类文章上来就是「把证书加上」。这对第二、第三行有用,对第一、第四、第五行完全没用。下面这一节的顺序,是为了让你在动手改之前先知道自己在哪一行。
按这个顺序排查
1 · 先看清楚对方实际出示的是什么证书#
这是全页信息量最大的一条命令。在形成任何猜测之前先跑它。
HOST=api.9coding.com
openssl s_client -connect "$HOST:443" -showcerts </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
读 issuer 那一行:
- 是你认得的公共 CA → 没有人在中间拆你的流量。去看日期;如果
notAfter已经过去,这是运营方的问题,不是你的。 - 是你公司的名字、你们安全产品的名字,或者你们内网的某个主机名 → 你的流量在传输途中被终止并重新签名了。这就是全部病因。跳到第 3 步。
- 命令卡住或什么都没返回 → 你碰到的是连通性问题,不是证书问题。请看连接错误。
2 · 确认运行时读得到系统信任库#
基于 Node 的客户端,既信任自带的那套 CA,也信任操作系统的信任库——但读系统信任库要求运行时版本够新。版本不够时,IT 明明在全机范围内正确装好的证书,对这个客户端是不可见的,而机器上其他一切照常工作。浏览器好使。curl 好使。只有这个客户端报错。
node -p "process.version"
node -p "typeof require('tls').getCACertificates" # "function" 表示读得到系统信任库
如果打印出 undefined,系统信任库就没参与,只有自带的那套加上 NODE_EXTRA_CA_CERTS 生效。要么升级运行时,要么继续走第 3 步——第 3 步在两种情况下都管用。
3 · 导出你们机构的 CA,然后让客户端指过去#
security find-certificate -a -p /Library/Keychains/System.keychain > ~/corp-ca.pem
security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain >> ~/corp-ca.pem
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
ForEach-Object {
"-----BEGIN CERTIFICATE-----"
[Convert]::ToBase64String($_.RawData, 'InsertLineBreaks')
"-----END CERTIFICATE-----"
} | Set-Content -Encoding ascii $HOME\corp-ca.pem
Linux——先把根证书按正规方式装进去,再用系统 bundle:
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt # Debian/Ubuntu
sudo update-ca-certificates
# RHEL/Fedora:放 /etc/pki/ca-trust/source/anchors/ 然后 `sudo update-ca-trust`
然后把变量指向 bundle,并重启客户端——这些变量在启动时读一次,正在跑的进程会一直沿用它启动时的环境:
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
# macOS/Windows:用你刚写出来的那个文件
在怪罪别的东西之前,先验证这个 bundle 真的能让证书链验通:
openssl s_client -connect "$HOST:443" -CAfile "$NODE_EXTRA_CA_CERTS" </dev/null 2>&1 \
| grep -i 'verify return code'
# 要的是:Verify return code: 0 (ok)
4 · 确认 bundle 被加载了,不只是被配置了#
这是两件事,而且绝大多数「我早就设了」的对话就卡在这里。状态界面显示了路径,说明的是变量设上了,不说明文件被读进去了。路径读不到、权限不对、PEM 换行符不对,都会造出「配了但没加载」的状态——从外面看一模一样。
带调试日志启动客户端,找那一行点名文件的记录:
claude --debug
# 然后读 ~/.claude/debug/ 下最新的那个文件
出现一行确认「已从 NODE_EXTRA_CA_CERTS 追加额外证书」并写出你的路径,说明加载成功。出现 failed to read 或 failed to load 会告诉你原因。一行都没有,也是失败——那说明变量根本没进到进程里。这在两种情况下天天发生:变量在一个 shell 里 export,客户端却是从另一个 shell 启动的;或者客户端跑在某个守护进程、后台代理底下,那东西压根没见过你的 shell。属于这种情况,就别写进 shell 配置文件,直接写进客户端自己的设置文件。
5 · 把客户端排除在外——但要分两路#
# <模型 id>:从 https://api.9coding.com/v1/models 里复制一个
curl -sS -w '\n%{http_code}\n' https://api.9coding.com/v1/messages \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"<模型 id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
node -e 'require("https").get("https://api.9coding.com/", r => console.log(r.statusCode))
.on("error", e => console.error(e.code, "-", e.message))'
两条都要跑,因为它们读的不是同一个信任库,而两者的差别就是答案:
curl成功、Node 那条失败 → 证书在全机范围内是好的,问题出在运行时的信任库。回到第 2、3 步。这是最常见的结果,也正是为什么「可是我浏览器能上」什么都证明不了。- 两条都报证书错 → 这台机器上没有任何东西信任那条链。第 1 步已经告诉你为什么了。
- 两条都成功、客户端还是失败 → 客户端读的不是你改的那份环境。看第 4 步最后一段。
那个你一定会想设、但不该设的变量
每一个讨论这个报错的帖子,最后都会冒出 NODE_TLS_REJECT_UNAUTHORIZED=0。它确实能让报错消失。它实际做的事是这样的。
它关掉的是整个进程的 TLS 证书校验——不是某一个主机,也不是某一次请求。这个进程往外发的每一条 HTTPS 连接,包括你根本没想到的那些,从此会接受任何能坐在你和目的地之间的人递过来的任何证书。而在一台流量本来就正在被拦截的机器上,这恰好是你原本用来察觉这件事的那个性质。
你手上原本是一个配置问题,第 3 步两分钟就能解决。这一下把它换成了一个安全问题:它是无声的,在有人发现之前会一直在,而且会跟着你复制过去的任何脚本和镜像一起扩散。如果你确实为了五分钟的实验设过一次,就在同一个会话里把它取消掉。
不存在「只是在内网端点上测一下所以没关系」的版本。信任一个指定的 CA 是同样的工作量,而且它让其他所有连接的校验都保持开着。
什么时候这不是你的问题
以下情况说明你看到的是运营方的证书,不是你的配置:
- 第 1 步显示签发者确实是公共 CA,而
notAfter已经过去, - 或者第 1 步显示只有叶证书、没有中间证书,且
curl报一样的错, - 并且换一台机器、换一个网络也能复现——手机热点就够了,
- 而且它是在你这边没有任何改动的情况下开始的。
证书过期是一件有人漏掉的排期事件,所以它通常以小时计恢复,不是以分钟计。在你花一下午重建信任库之前,先去看运营方的状态页——我们的在状态页。
提问题时请带上 request id——9Coding 的报错响应里都有一个,形如 (request id: 2026...)。这个 id 加上时间戳、模型名和完整报错文本,就能把那一次调用查出来。只说「用不了」的反馈没法排查。
证书错误和连接错误不是同一个问题
证书错误意味着连接建立成功了,然后校验没过——你已经走到了「被递了一张证书」这一步。ECONNREFUSED、ETIMEDOUT、fetch failed 则表示你根本没走到那里,配多少 CA 都不会有任何变化。它们还会被重试,这正是它们感觉慢、而这一个感觉瞬间的原因。请看连接错误。
相关报错
- 连接错误 — 分辨网络、代理与端点
- 401 Unauthorized — 两层认证,一样的报错信息
- 429 Too Many Requests — 为什么你看见它的时候,重试已经用完了
- 全部报错速查 — 排查手册总表