Better Code: 异常时, 该提示用户哪些信息?

接前一篇 Better Code: 更好的异常日志打印

做 toB 一个非常高的成本是, 用户的环境/网络/数据等, 可能跟你预期的差异很大, 再加上沟通相对困难(涉及三方/四方, 无法便捷地登机器排查问题, 无法便捷地获取日志, 接口人业务或技术水平参差不齐).

而这时候, 你会面临一个问题, 如何在信息不足/沟通不畅的场景下, 尽量提升效率, 降低成本(大部分情况下, 逢单必结, 你必须解决问题, 没有选择的余地)

TLDR:

  1. 如果有工具, 尽量用工具解决问题, sentry/otel等等
  2. 日志, 该打的地方, 一定要打, 并且尽量包含上下文; 并且能通过日志级别配置动态调整打印明细.
  3. 页面报错提示, 尽量详尽, 用户不一定能看懂, 但是配合文档或者截图给你, 你能秒懂(如果不方便展示给用户, 可以通过request_id或者隐藏在页面等方式)
  4. 尽量减少排查链路(例如, 尽量汇总日志, 尽量在一个地方能看到, 或者通过一个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服务器

  1. 由于网络/ldap服务器类型及配置差异较大, 导致非常容易出现异常
  2. ldap服务器往往由另一个团队维护, 具体的服务端配置对于配置用户并不透明
  3. ldap3库本身不同版本也存在bug

此时, 用户配置了ldap相关参数, 但是点击报错, 并且, 用户坚信自己的配置是对的; 如何帮助用户调试?

我们是否可以支持, 开启ldap3的debug模式?

  1. 首先, 需要确认库是否支持logger配置, 常用的第三方库一般都支持, 例如requests, ldap3是支持的, ldap3 logging
  2. 其次, 通过配置/环境变量, 支持用户可以开启

上面的例子, 我们的应用实现上支持

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: 日志链路需要串起来

如果:

  1. 调用链路长, 用户 -> A -> B -> C -> D
  2. 服务请求量非常大

此时, 某个用户一个请求报错(前端或 API), 如何定位问题?

首先明确一个原则, A -> B 如果出现调用失败, 一定要记录日志, 包括url/request detail(header/body/credentials)/response detail(status/header/body)等等(如果什么都不记, 让用户或运维去 B 看日志, 这是不合理的!)

此时, 如果请求量很大, 调用链路长, 其实很难逐层定位每一层的处理/报错, 光去捞日志就已经非常费劲了(大部分时候, url或请求中并没有明显的特征)

如果整个调用链路接入了 OTEL, 那么问题相对简单; 但是如果没有接入, 如何处理?

我们可以通过 HTTP HEADER中带一个request_id来解决!

  1. 约定一个统一的header, 例如x-request-id
  2. 网关/ESB/nginx等地方, 需要显示配置透传
  3. 程序处理逻辑, 需要在接收请求时, 获取request_id, 如果有向下一级调用的请求, 需要传递该request_id
  4. 日志,异常上报等场景, 使用统一的request_id

tip: 当开启debug的时候, 确保能看到足够多的信息

logging.DEBUG主要目的是, 开启后, 可以准确定位问题!

所以, 在一些关键的逻辑中

  1. 加上debug日志, 覆盖所有关键的处理逻辑和返回
  2. 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/网络/依赖库/依赖包版本等, 重新排查调用链路, 追踪请求流转过程, 重新获取日志

场景: 对方上了网络策略, 存量机器没有处理导致原先正常的环境突然有问题了

很多时候, 你被拉去排查问题, 并不是因为你负责的服务有问题, 而是更底层的环境问题, 只是因为环境出问题你的服务刚好打了异常, 对方看到了而已.