Why NextWhy Next
返回列表
💻 开发阅读约 6 分钟

「staging 里关掉不就好了」-吵闹的 404 告警,和没被真正修复的问题

staging 每天都会弹出同一条 Google Play 404 告警。把源头关掉,告警确实安静了,但那和修复失败模式是两回事。404 是一种重试也没用的永久性错误,却和 5xx 混在一起被当成可重试对待-这是一次排查的记录。

核心摘要

在丢进队列重试的任务里,如果外部 API 抛出 404,这个 404 往往是重试也不会变好的永久性状态。可一旦客户端把 404 和 5xx、429 揉进同一种异常,就会烧掉重试预算、填满告警列表。解法是在最先看到响应码的客户端边界,把永久性错误(4xx)和临时性错误(5xx、429、超时)分开。而在吵闹的告警面前,「关掉源头」和「修复失败模式」是两个不同的问题-前者只消除眼前这一条告警,后者消除的是这个失败以后会以任何形式再次出现的所有可能。

目录

每天同一个时间,都会弹出同一条告警-staging 环境的订阅对账(reconcile)任务失败的 ERROR。凌晨 3:17 定时任务跑起来,3 分钟后告警就到。堆栈信息永远从同一行开始:Google Play Voided Purchases API returned 404。重试三次,三次都死在同一个 404 上,任务掉进 DLQ,我们的 Slack 里又多了一条红色告警。

这不是故障。什么都没坏,只是吵。而在决定怎么处理这条吵闹的告警时,我又一次学到:「关掉」和「修复」是两个不同的问题。

为什么会是 404

Voided Purchases API 是 Google Play 的一个端点,返回已退款、已取消的购买记录。我们用它每天做对账,揪出那些偷偷退款、却还继续白嫖 App 的用户。但这个端点抛出 404,并不代表「没有数据」。在 voidedpurchases.list 里,404 的意思是找不到对应的应用(包名),或者服务账号没有权限访问那个包。

staging 的服务账号本来就没有生产包名的 Play Console 访问权限,也没有理由拥有。staging 不是真实商店退款流量会流经的环境。所以在 staging 调用这个 API,结果必然是 404-今天是,明天也是,只要配置不变,就会一直是。这不是断断续续、时好时坏的临时故障,而是这个环境本身造成的永久性状态。

关键的分歧点就在这里。如果是临时性故障,重试是对的-网络抖一下,或者对方服务器短暂返回 5xx,过几秒再调一次就好了。但永久性状态不会因为重试而变好。staging 服务账号没有的权限,不会在 3 秒后凭空冒出来。所以把这个 404 重试三次,纯粹是浪费;而随之而来的 ERROR 告警,纯粹是噪音。

代码为什么会重试

原因出在客户端分类响应的方式上。listVoidedPurchases 把所有非 200 的响应都揉成了同一种异常抛出。

if (!res.ok) {
  throw AppHttpException.serviceUnavailable(
    `Google Play Voided Purchases API returned ${res.status}`,
  );
}

serviceUnavailable 是我们的队列会识别为「可重试」的错误类型。于是不管是 404、500 还是 429,从这里抛出的错误都会被重试三次,最后统统以告警收场。问题在于,这三种状态的含义完全不同。500 和 429 的意思是「现在不行,但很快可能就行了」;404 的意思是「在这个条件下,以后也不会行」。一旦把它们揉进同一个异常,重试也没用的错误就会被送上重试流水线。

有意思的是,正确答案其实早就在同一个文件里。同一个客户端的 getSubscriptionPurchase 方法,早就把 404 单独分支处理了。

if (res.status === 404) {
  throw new GooglePlayApiError(404, "purchase not found");
}

一个方法尊重了 404 的含义,另一个方法把它抹平了。先例就在代码里,只是新方法没有照着做。这种不一致,通常出现在不同的日子、出于不同的关注点分别写方法的时候。

修复:在边界处分开

修复分两层进行,两层都沿用了已有的模式。

首先在客户端边界把 404 分离出来。404 被抛成含义为「找不到」的 GooglePlayApiError(404),其余的 5xx、429、超时仍然保留为 serviceUnavailable。这样一来,只有真正需要重试的错误,才留在可重试的范围内。

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx·429·超时 = 真正需要重试的对象 */);
}

接下来,要决定对账服务该如何接住这个 404。服务里本来就有一条「服务账号密钥不存在时静默跳过」的流程(isConfigured === false)。404 本质上是同一种情况-在这个环境里,这个 API 根本调不通。所以把它归到同一处处理。

try {
  const voided = await client.listVoidedPurchases();
  // ... 继续对账
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - 该环境无法访问,已跳过");
    return { skippedAppNotFound: true };
  }
  throw e; // 不是 404 就照常重新抛出,走正常重试
}

catch 里只吞掉 404,记一条 warn 日志,再返回 skip 统计。不重新抛出,也就没有重试、没有 DLQ、没有 ERROR 告警。反过来,非 404 的错误照常重新抛出,原来的重试逻辑正常运作。吵的部分安静下来,真正的问题依然会吵。

再补上测试:客户端在 404 时是否抛出 GooglePlayApiError,对账逻辑是否会跳过 404,以及非 404 的错误是否仍然会被重新抛出。最后一条尤其重要-如果为了让 404 安静下来,顺手把 500 也一起吞掉了,那真正的故障就会被藏起来看不见。

「在 staging 关掉不就好了?」

PR 提交后,有人问了一个好问题:staging 里干脆别跑这个任务不就行了?这个直觉是对的-staging 没有真实的退款流量,本来就没理由调用这个 API。所以我去确认了一下。

关掉这件事,没有想象中简单。在 staging 关掉这个功能,自然的开关是服务账号密钥存不存在(isConfigured),但那把密钥不是这个任务专用的。App 内购收据校验、Google 实时通知(RTDN)webhook 处理、订阅对账处理器-整个订阅校验链路都挂在同一把密钥上。要在 staging 测试支付和订阅,就得有这把密钥。把密钥拿掉来关掉这个任务,真正需要测试的东西也会一起被关掉。

那做一个只关这一个任务的专用开关呢?这样的开关不存在,意味着要新加一个类似 GOOGLE_VOIDED_RECONCILE_ENABLED 的开关。也就是说,「只在 staging 关掉」最终也要动代码和环境变量。免费关掉这条路,从一开始就不存在。

关掉和修复是两回事

但真正的重点不在于难易程度。就算做这个开关很容易,它也代替不了 404 的修复,因为这两种做法解决的是不同的问题。

  • 把 404 从可重试范围里拿掉,是根本性的防护。不管在哪个环境-staging,配置出错的生产环境,还是未来某个忘了打开开关的场景-都能防止一个重试也没用的 404 演变成告警风暴。
  • 在 staging 关掉这个任务,是运维层面的优化。「这个环境本来就没理由调用,那就干脆不调」,是可以叠加上去的加分项。

顺序很重要。留着 404 的防护,再加一个开关,没问题。但如果只加了开关、却拿掉了 404 的防护,那总有一天生产环境配置出错、真的出现 404 时,告警会重新炸开。到那时候,也没法用「反正是 staging,不用管」来搪塞。关掉源头,只是消除了眼前这一条告警;修复失败模式,消除的是这条告警以后会以任何形式再次出现的所有可能。

碰到吵闹的告警,手总是先伸向源头-关掉这个任务、删掉这条告警规则、把它从这个环境里拿掉。这是很自然的冲动,有时候这么做也没错。但动手关之前,得先问自己一句:我现在是在关掉告警,还是在修复告警指出的那个失败模式?前者只是让这一条告警安静下来,后者才能防止这个失败换个样子卷土重来。

在边界处该问的问题

这个故事不只关于 Google Play 的 API。任何调用外部服务、把失败丢进队列重试的代码,都会站在同一个岔路口上。在动队列的重试策略之前,应该先在客户端边界问一句。

这个错误,重试了会变好吗?5xx、429、超时,通常会-现在不行,不代表马上也不行。4xx 通常不会-404、403、400 在条件改变之前,给出的答案都是一样的。把这两类揉进同一个异常,重试也没用的失败就会烧掉重试预算、填满告警列表。该分开的地方不是队列,而是最先看到响应码的客户端边界。

修复之前,也别忘了先把代码库过一遍。在我们这个案例里,同一个文件里紧挨着的另一个方法,早就把 404 处理对了。根本不需要重新发明。答案已经在代码里,只是新代码没照着做-这种情况,比想象中更常见。

常见问题

后台任务因为 404 一直重试,失败告警反复出现,该怎么处理?

首先要判断这个 404 是临时性的还是永久性的。如果是因为资源在这个环境里本来就不存在、或者没有访问权限而导致的 404,重试的结果永远一样。这种情况下,正确做法是在客户端最先看到响应码的地方,把 404 单独分支成一种错误类型,让任务不再重新抛出,而是记一条 warn 日志后优雅跳过(graceful skip)。这样就不会再触发重试、DLQ 和 ERROR 告警。

临时性错误和永久性错误怎么区分?

大体上,5xx、429、超时都是临时性的,值得重试,因为现在不行不代表马上也不行。而 404、403、400 这类 4xx,在请求或条件本身改变之前,给出的答案都是一样的,重试也没用。这个区分最好放在最先看到响应码的客户端边界去做,而不是放在队列的重试策略里。

相关文章