Better Code: 异常时, 该提示用户哪些信息?
做 toB 一个非常高的成本是, 用户的环境/网络/数据等, 可能跟你预期的差异很大, 再加上沟通相对困难(涉及三方/四方, 无法便捷地登机器排查问题, 无法便捷地获取日志, 接口人业务或技术水平参差不齐).
而这时候, 你会面临一个问题, 如何在信息不足
/沟通不畅
的场景下, 尽量提升效率, 降低成本(大部分情况下, 逢单必结, 你必须解决问题, 没有选择的余地)
TLDR:
- 如果有工具, 尽量用工具解决问题, sentry/otel等等
- 日志, 该打的地方, 一定要打, 并且尽量包含上下文; 并且能通过日志级别配置动态调整打印明细.
- 页面报错提示, 尽量详尽, 用户不一定能看懂, 但是配合文档或者截图给你, 你能
秒懂
(如果不方便展示给用户, 可以通过request_id
或者隐藏在页面等方式) - 尽量减少排查链路(例如, 尽量汇总日志, 尽量在一个地方能看到, 或者通过一个
request_id
能汇总相关日志等等)
场景 1: 代码逻辑报错
有个代码初始化数据到数据库的逻辑报错了, 不管是什么原因引起的, 你需要解决!
实现v1
Setting.objects.create(
key=k, value=v, defaults=x
)
此时, 新装环境没问题, 升级环境会报错,
通过异常信息知道创建由于唯一性限制
报错了, 是由于用户手动插入了数据导致报错
此时, 用户问你, 数据有几百条, 哪条数据引起的? 我该怎么修复(成本: 小时级)
实现v2
打印上下文!
try:
Setting.objects.create(
key=k, value=v, default=x
)
except Exception as e:
logger.exception("create setting fail, k=%s, v=%s, default=%s", k, v, x)
raise e
此时, 通过异常信息上下文可以看到哪条数据有问题!(成本: 分钟级)
实现v3
代码考虑兼容性
, 已发的版本/存量的系统, 最好
能顺滑升级!
例如, 这里的create
, 可以考虑用get_or_create
替代(当然, 前提是符合需求)
场景 2: 用户输入报错了
有个功能是支持用户配置ldap相关地址, 拉取用户数据, 用户点击:
测试连接
.
实现v1
try:
test_connection(**params)
except Exception as e:
raise Exception("测试连接失败, 请检查配置")
此时, 用户坚信自己的配置是对的, 你如何帮他定位呢? (成本: 几个小时甚至一两天)
实现v2
try:
test_connection(**params)
except Exception as e:
# 注意, 这里一定要logger.exception, 用error意义不大!
logger.exception("测试连接失败, 请检查配置")
raise Exception("测试连接失败, 请检查配置")
这时候的调试方式, 用户找你, 你告诉用户去哪里看日志!(成本: 分钟到几个小时)
实现v3
try:
test_connection(**params)
except Exception as e:
# 注意, 这里一定要logger.exception, 用error意义不大!
logger.exception("测试连接失败, 请检查配置")
error_detail = f" ({type(e).__module__}.{type(e).__name__}: {str(e)})"
raise Exception("测试连接失败, 请检查配置" + error_detail)
这时候, 在页面上, 用户就能看到 报错的类型及错误信息
- 报错类型: 能定位到是第三方库还是自己程序哪个模块报的, 如果出现标准库
KeyError/ValueError
等, 大概率是自己哪里代码逻辑处理不正确. - 报错信息: 例如
invalid server address
,socket connection error while opening: [Errno 111] Connection refused
之类的;
此时, 用户绝大多数可以在前端提示中看到, 无需支持!
实现v4
有必要的话
- 如果你能准确根据 Exception 或message区分详情, 可以进一步处理, 返回更精确的提示. 例如
invalid server address
, 可以翻译成不合法的地址, 请检查xxxx字段, 必须是一个合法的域名
, 建议同时带上一个error code
方便快速确定问题/文档中检索关键字 - 如果有必要, 可以在错误中带上
上下文
, 但是需要注意脱敏
场景 3: 用户输入, 报错了, 但是不能直接提示给对方
用户登录, 输入的用户名/密码有问题或者账号本身有问题时;由于安全原因, 不能明确告诉对方具体是哪里有问题. 在用户看来, 无论怎么尝试, 报错总是一样的.
那么, 如果用户坚持
自己输入是对的, 账号是正常的, 如何帮助其排查问题?
实现v1
if user not exists:
raise PasswordWrongException()
if user.status in [DISABLED, LOCKED]:
raise PasswordWrongException()
if user login hit secure policies:
raise PasswordWrongException()
这种实现, 你只能看代码, 逐个同用户沟通检查(成本: 小时/天)
实现v2
if user not exists:
logger.error("login fail, user<%s> not exists", user)
raise PasswordWrongException()
if user.status in [DISABLED, LOCKED]:
logger.error("login fail, user<%s> status is %s", user, user.status)
raise PasswordWrongException()
if user login hit secure policies:
# 这里有必要的话, 甚至需要明确到具体哪个policy
logger.error("login fail, user<%s> hit xxx secure policies", user)
raise PasswordWrongException()
这种实现, 让用户捞下日志就能明确具体问题(成本: 分钟)
场景 4:强依赖于一个第三方库, 如何 debug?
如果应用本身依赖于一个第三方库, 且第三方库默认是有logger的.
此时, 调用第三方库报错, 可能返回的异常信息不足以帮助用户定位问题;
例如依赖ldap3
同步用户数据
此时数据链路: 应用 -> ldap3 -> 网络 -> ldap服务器
- 由于
网络
/ldap服务器
类型及配置差异较大, 导致非常容易出现异常
ldap服务器
往往由另一个团队维护, 具体的服务端配置对于配置用户并不透明ldap3
库本身不同版本也存在bug
此时, 用户配置了ldap相关参数, 但是点击报错, 并且, 用户坚信自己的配置是对的; 如何帮助用户调试?
我们是否可以支持, 开启ldap3
的debug模式?
- 首先, 需要确认库是否支持logger配置, 常用的第三方库一般都支持, 例如
requests
,ldap3
是支持的, ldap3 logging - 其次, 通过配置/环境变量, 支持用户可以开启
上面的例子, 我们的应用实现上支持
from ldap3.utils import log as ldap3log
if settings.ENABLE_LDAP3_DEBUG:
ldap3log.set_library_log_detail_level(ldap3log.EXTENDED)
ldap3log.set_library_log_activation_level(logging.DEBUG)
那么, 在用户遇到问题无法解决的时候, 可以通过配置开启, 进入debug模式; 在日志中可以看到详细的 ldap3 -> 网络 -> ldap服务器
的数据细节.
其他
tip: 如果有可能, 接入sentry/otel
如果基建有otel, 建议接入; 如果基建有日志采集汇聚, 建议接入!
能极大提升问题排查效率
所以, 尽量整合相应的SDK, 并且提供可插拔的配置方式, 让有条件的使用方开启.
tip: 日志链路需要串起来
如果:
- 调用链路长,
用户 -> A -> B -> C -> D
- 服务请求量非常大
此时, 某个用户一个请求报错(前端或 API), 如何定位问题?
首先明确一个原则, A -> B
如果出现调用失败, 一定要记录日志, 包括url/request detail(header/body/credentials)/response detail(status/header/body)
等等(如果什么都不记, 让用户或运维去 B
看日志, 这是不合理的!)
此时, 如果请求量很大, 调用链路长, 其实很难逐层定位每一层的处理/报错, 光去捞日志就已经非常费劲了(大部分时候, url或请求中并没有明显的特征)
如果整个调用链路接入了 OTEL, 那么问题相对简单; 但是如果没有接入, 如何处理?
我们可以通过 HTTP HEADER中带一个request_id
来解决!
- 约定一个统一的header, 例如
x-request-id
- 在
网关
/ESB
/nginx
等地方, 需要显示配置透传
- 程序处理逻辑, 需要在接收请求时, 获取
request_id
, 如果有向下一级调用的请求, 需要传递该request_id
- 日志,异常上报等场景, 使用统一的
request_id
tip: 当开启debug的时候, 确保能看到足够多的信息
logging.DEBUG
主要目的是, 开启后, 可以准确定位问题!
所以, 在一些关键的逻辑中
- 加上
debug
日志, 覆盖所有关键的处理逻辑和返回 debug
需要带上足够多的上下文
例如, 当关键一个函数, 中间有超过 5 个return
, 开启debug
之后, 需要能明确知道, 在哪里return
的, 为什么会被return
tip: 输入尽量做好防御
不管是用户的输入, 还是从某些 API 拉取到的返回值, 尽量在获取到数据的入口
处做好防御和检查, 例如, 类型/格式/非空
等等
而不是, 层层传递后, 在某个地方异常报错, 这个时候排查的代价会大很多.
tip: 当问题排查陷入僵局
1. 如果是第三方库, 可以考虑升级试试?
场景: 依赖于ldap3, 能确认用户的配置是正确的, 网络是联通的, 此时无论如何都连不上, 开debug模式也没看出问题
解决: 确定库版本, 2.6.1, 在官方github issue的bug report中查找相关报错信息, 然后升级了一个版本就成功了
常见的, 例如requests/加密相关库/ssl相关库等等
2. 重启, 本机重新部署或在另一台机器部署, 也是可以尝试的方式
场景: 在一台机器部署, 连接另外一个服务始终连不上, 运维确定网络是没问题的; 后来在另一台机器部署后正常.
场景: 有一台机器reuqests
库一直会报错, 换一台机器部署正常.(openssl相关)
3. 重新检查配置/负载/io/网络/依赖库/依赖包版本等, 重新排查调用链路, 追踪请求流转过程, 重新获取日志
场景: 对方上了网络策略, 存量机器没有处理导致原先正常的环境突然有问题了
很多时候, 你被拉去排查问题, 并不是因为你负责的服务有问题, 而是更底层的环境问题, 只是因为环境出问题你的服务刚好打了异常, 对方看到了而已.