一个监控客户端,列表里十几个节点全部显示"不通"。第一反应当然是网络问题:挨个 ping、查防火墙、翻交换机日志,折腾大半天毫无头绪。真正的元凶其实藏在代码里,而且它一开始就伪装成了"超时"。
现象
客户端里所有节点统一报"请求超时/连接不上",日志里翻来覆去就是一句话:
[WARN] node=<IP> 请求超时,标记为不可用
[WARN] node=<IP> 请求超时,标记为不可用
...(每个节点一行)
看起来像是网络整体不通,但用别的方式访问这些节点的接口又是正常的。
根因
问题出在一个两级拼接的封装上。这个封装函数会替调用方自动加上接口前缀:
def fetch(host, path):
url = f"http://{host}:9090" + path # 自动拼前缀
try:
return http_get(url)
except Exception:
return None # 异常被静默吞掉
而调用方传进去的,却是已经带好了前缀的完整 URL:
fetch(host, "http://<IP>:9090/api/status")
于是拼出来的地址变成了:
http://<IP>:9090http://<IP>:9090/api/status
这种畸形地址当然连不上,底层网络库抛出连接/解析异常。可偏偏封装里那个 except Exception 把异常全吞了,只返回 None;上层拿到 None,又把它一律解读成"超时",于是真正的"URL 拼错"被彻底掩盖,最终表现为"所有节点都不通"。
这里其实叠了两个 bug:
- 调用约定不统一——同一个封装,一处只传路径、一处又传完整 URL,两套约定并存。
- 异常被静默吞掉——
except Exception: return None这种写法,把"出错"和"正常返回空"两种完全不同的情况揉成了一个None,线索就此丢失。
解决
第一步,统一调用约定。 让封装函数只接收"路径",绝不再接收完整 URL;或者干脆反过来,让它只接收完整 URL,由调用方负责组装。二者选其一,全局统一:
def fetch(host, path):
# 约定:path 只传路径,如 "/api/status",不带 scheme 和 host
if path.startswith("http"):
raise ValueError(f"fetch() 只接受路径,收到完整 URL:{path}")
url = f"http://{host}:9090" + path
return http_get(url)
在函数入口加一道断言/校验,能在传错约定的那一刻就当场报错,而不是拖到网络层。
第二步,不要让异常无声消失。 至少要记录日志,或者原样抛出:
def fetch(host, path):
url = f"http://{host}:9090" + path
try:
return http_get(url)
except Exception as e:
logger.warning("请求失败 url=%s err=%r", url, e) # 带上真实地址和异常
raise
这样日志里会直接打印出那个畸形的 http://...9090http://...,一眼就能看出问题。
延伸与预防
这条坑的两个教训都很通用:
- 两级拼接的封装,调用约定必须单一。 只要有一个组件"替别人拼好了",另一个组件就不该再拼一次。任何"自动补全"的功能,如果没有校验,都可能和已经补好的输入撞车——URL、路径、文件后缀、命名前缀,全是同一类问题。
- 被吞掉的异常会把 bug 伪装成别的形式。
except Exception: pass、except Exception: return None这类写法,实际是在销毁证据。真正该做的是:能处理的异常就地处理,处理不了的让它带着上下文抛出去;最次也要落一条日志。当你在排查一个"看起来像 A 问题"的现象却发现怎么都修不好时,不妨先搜一遍代码里有没有把它误报成 A 的except——很可能原始异常根本不是 A。