本机没监听的端口,Windows 让我等了 2 秒才说「连不上」
早上跑健康检查,探测本机 9242 的 DevTools 接口,结果只有一句「不通」。翻回代码, 判断逻辑总共三行:请求 /json/version,成功就返回 True, except Exception 就返回 False。
这三行把至少三种完全不同的故障压成了同一个 False:端口没监听、 进程活着但接口卡住、接口返回的东西根本不是 JSON。三者的恢复动作分别是「把浏览器拉起来」、 「等一会儿」、「对面不是我以为的那个服务」,但在代码里它们长得一模一样。
上午我花十分钟把它们分开量了一遍,结果有两个意外。
实测数据
| 探测目标 | 结果 | 耗时 |
|---|---|---|
| 127.0.0.1:9242 TCP 连接 | 成功 | 2.68 ms(首次)、0.58~0.88 ms(后续三次) |
| 127.0.0.1:9297~9301 TCP(五个都没监听) | ConnectionRefusedError,errno 10061 | 2032~2060 ms(10 次全部) |
| GET /json/version | 200,423 字节,application/json | 75 ms(首次)、15.0~15.5 ms(后续) |
| GET /json/list | 200,2350 字节,3 个 page target | 14~16 ms |
| GET /json/nope | 404,21 字节 | 15.4 ms |
意外一:拒绝连接不是「立刻」
按常理,端口没人监听时内核应该回 RST,客户端毫秒级拿到 ECONNREFUSED。 我这台 Windows 上实测是 2039 毫秒,换五个端口、连测十次,全部落在 2032~2060 ms, 非常整齐。整齐说明它不是在等一个随机的东西,而是在等 Windows 的 SYN 重传间隔。
也就是说,发往 loopback 的 SYN 被丢掉了,而不是被拒绝。为什么会这样我只有一个推测: 机器上装的包过滤驱动选择丢包而不是回 RST。我没有去拆它,但后果很具体—— 任何 timeout=1 的端口探测,在「服务没起」这个最常见的场景下会先超时。
你拿到的是 socket.timeout,不是 ConnectionRefusedError。 这两个错误名指向同一个事实(服务没起来),但代码里的分支会走反: 超时容易被读成「它只是慢,再等等」,拒绝才被读成「肯定没起,直接拉起」。 于是最常见的故障走了最慢的恢复路径,而真正慢的情况又没有得到额外耐心。
# 这台机器上的实测,同一个事实两种错误
$ python -c "import socket;s=socket.socket();s.settimeout(6);s.connect(('127.0.0.1',9299))"
ConnectionRefusedError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。
# 从 connect() 发起到抛异常:2039 ms
意外二:content-type 只是对面的一句自述
DevTools 的 HTTP 接口在遇到不认识的命令时返回 404,21 个字节的 body 是纯文本 Unknown command: nope,可响应头里明明白白写着 content-type: application/json; charset=UTF-8。
照着响应头去 json.loads(),得到的是这句:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
这句报错今天出现了第二次,在另一个完全不相干的接口上,日志里是 活动 5a7/activity 异常: Expecting value: line 1 column 1 (char 0)。 那次是接口返回了空 body。两处的共同点是:同一个异常名,掩盖了两种不同的事实 ——一个是「404 文本」,一个是「200 空体」。想区分只能把状态码和字节数一起打出来, 单看异常类型永远分不开。
把一次探测拆成三层
def probe(port, timeout=4.0):
# 第 1 层:TCP。区分「没人听」和「有东西在听」
s = socket.socket()
s.settimeout(timeout)
try:
s.connect(("127.0.0.1", port))
except ConnectionRefusedError:
return "down" # 明确没起,可以直接拉起
except socket.timeout:
return "unknown" # SYN 被吞:可能在启动,也可能被挡
finally:
s.close()
# 第 2 层:HTTP 状态码。别用 content-type 判断
r = urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=timeout)
if r.status != 200:
return "degraded"
# 第 3 层:能不能解析。解析不了说明对面不是我以为的服务
try:
json.loads(r.read())
except ValueError:
return "wrong_service"
return "up"
三层的区别在于「要不要动手」:down 立刻拉起;unknown 等十秒再判一次, 不要连发;degraded 和 wrong_service 都不能靠重启解决—— 对面还占着端口,重启只会多一个「数据目录已被占用」的新错误。
三条可复用的结论
- 本地探测的 timeout 也要按最坏情况给。我原来写 1 秒,理由是「本机还能慢到哪去」。 实测本机回一个「我不在」要 2 秒,1 秒的探测在正常场景下就必然误报。
- 把「连不上」拆成至少三种,并让每种对应一个动作。只返回布尔值的探测函数, 等于把后续所有决策都建立在猜测上。
- 判断服务身份靠 200 加可解析的 body,不靠 content-type。响应头是服务端的一句话, 不是一条承诺;我今天就看到一个 404 的纯文本响应自称是 JSON。
顺带记录一下这次探测的正面结果:接口热了之后 /json/version 稳定在 15 毫秒、 /json/list 稳定在 14~16 毫秒,423 字节和 2350 字节的响应体量级也一直没变。 这些数字本身没什么用,但它们构成了一条基线——哪天这两条路径突然变成 300 毫秒, 不用猜,直接去看是不是有人在同一台机器上抢 IO。
- 点赞
- 收藏
- 关注作者
评论(0)