超时不是失败:每个 AI 都会写的邮件 bug

Dev.to AI 2026-10-28T16:06:15.273822

我给意大利小公司做后台系统:客户关系管理(CRM)、预约流程,以及那些给对的人发对邮件的枯燥程序。这些代码大部分是我和 AI 助手一起写的。我不掩饰这一点,这就是我的工作方式。但有一个 bug,我的助手几乎每次都会写出来,每个项目都有,除非我拦住它。它看上去像是优秀的工程实践,能通过测试,到了生产环境却会给你的客户发两遍同样的邮件。

看起来很对的代码

让 AI“发送确认邮件,写得健壮一点”,你会得到类似这样的东西:

async function sendConfirmation(booking: Booking) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      await emailProvider.send(confirmationFor(booking));
      return;
    } catch (err) {
      if (attempt === 3) throw err;
      await sleep(1000 * attempt);
    }
  }
}

重试、退避、最后抛出异常。代码审查的人会点头通过。我累的时候也会。

问题藏在那段 catch 里。它把所有错误都当成一回事:邮件没发出去,再试一次。对某些错误来说,这是对的;但对生产环境里最常见的那种错误来说,不是。

超时到底告诉了你什么

假设发给邮件服务商的请求在 10 秒后超时。你能确定什么?其实很有限。

也许请求根本没离开你的服务器。也许它已经到了服务商那里,服务商把邮件排进了队列,只是响应在返回路上丢了。也许服务商今天就是慢,而邮件早就躺在客户的收件箱里了。

超时不等于“失败”,它等于“我不知道”。

这算不上新知识:Martin Kleppmann 在《Designing Data-Intensive Applications》里讲不可靠网络时,最先提到的就是这个。收不到回应的时候,你分不清是请求丢了还是响应丢了。但负责处理的代码只有三行,还是别人(或者“别的东西”)写的,这就很容易被忘掉。

而当你对“我不知道”做出重试,有时候就会发两遍。对一封新闻简报来说,这只是尴尬。对预约确认、密码重置、发票或者医疗报告来说,那就是一张客服工单,或者更糟,一个不再信任你系统的客户。

测试之所以抓不到这个问题,是因为测试里用的假邮件服务要么成功、要么干脆失败。而真实网络存在第三种状态,只是没人给它写过测试。

先看真正的解决方案(如果你用得上)

如果你的邮件服务商支持幂等键(idempotency key),那就用它。每封邮件都带一个唯一键发出去,服务商记住这个键,之后用同一个键重试就会被忽略,不会重复发送。支付 API 就是靠这个办法让重试变安全的——Brandur Leach 写过一篇讲 Stripe 式幂等键的经典文章,Malcolm Featonby 在 Amazon Builders' Library 里的建议也是这个思路:先让写操作幂等,然后就可以放心重试。

可惜很多事务性邮件 API 并不提供幂等键。我自己用的那个就没在文档里提到。所以接下来要讲的,是对方没法帮你去重时该怎么办。

三种结果,不是两种

发一封邮件其实有三种可能的结果:

在代码里,我把第三种单独定义成一个类型,这样无论人还是 AI,都不会不小心把它当成前两种处理:

/**
 * 服务商拒绝了请求,或者请求根本没送到:邮件没发出去。
 * 可以安全重试。
 */
export class DeliveryRefused extends Error {
  override readonly name = 'DeliveryRefused';
}

/**
 * 我们不知道邮件有没有发出去:超时没收到回复,或者请求发出后连接断了。
 */
export class DeliveryUncertain extends Error {
  override readonly name = 'DeliveryUncertain';
}

有意思的地方在于判断某个错误到底属于哪一种。我的规则是:只有能证明邮件没发出去的错误,才算“被拒绝”。

在网络层面,这意味着请求从未抵达:DNS 名字解析不了、端口上没人监听、网络没有路由、连接根本没建立起来。

在 HTTP 层面,这意味着服务商看了请求并明确说了“不”(4xx)。

除此之外,其他情况都是不确定的,首当其冲的就是超时,5xx 也一样:网关返回 502 或 504,说不定在放弃之前已经把请求转发出去了。

// 能证明请求根本没到提供商那边的网络错误
const NEVER_ARRIVED = new Set([
  'ENOTFOUND',
  'EAI_AGAIN',
  'ECONNREFUSED',
  'EHOSTUNREACH',
  'ENETUNREACH',
  'UND_ERR_CONNECT_TIMEOUT',
]);

// 邮件适配器:唯一知道该怎么和提供商对话的地方
async function sendEmail(msg: Message, config: EmailConfig): Promise<void> {
  let res: Response;
  try {
    res = await fetch(config.providerUrl, {
      method: 'POST',
      body: JSON.stringify(msg),
      signal: AbortSignal.timeout(config.timeoutMs),
    });
  } catch (err) {
    const code = networkErrorCode(err); // 读取 err.code 或 err.cause.code
    if (code && NEVER_ARRIVED.has(code))
      throw new DeliveryRefused(`never arrived (${code})`);
    throw new DeliveryUncertain('no answer, the email may have gone out');
  }
  if (res.status >= 500)
    throw new DeliveryUncertain(`provider answered ${res.status}, it may have gone out`);
  if (!res.ok)
    throw new DeliveryRefused(`provider rejected it (${res.status})`);
}

注意这里的默认处理方式:拿不准的时候,代码判定为「不确定」。AI 往往写出相反的逻辑:它把吓人的错误一个个列出来,剩下的统统当成「可以重试」。安全的默认值必须明明白白写进代码,因为它不是那种顺理成章就能想到的写法。

(读取类请求另当别论。如果只是读取的请求超时了,对方那边什么都没变,那就可以重试——加上退避、一点随机抖动和次数上限,这正是《Google SRE 工作法》和 Marc Brooker 关于退避与抖动的文章都推荐的,这样你的重试才不会全堆到一个已经在苦苦支撑的提供商身上。这条规则只针对写入类调用:发邮件、扣款、发消息。)

先认领,再发送

光有三种结果还不够。如果服务器在发送过程中重启了,重试逻辑就没了,后台任务可能把「没发出去」的邮件捡起来再发一遍。所以发送之前,代码会先在数据库里认领这封邮件。

「认领」(claim)就是一行记录,意思是「这封邮件正在发送,别人别碰」。它单独写入并提交,发送在之后进行,等待服务商响应期间不占用数据库事务。只有认领成功才真正发送,接下来怎么处理取决于结果:

type SendOutcome = 'sent' | 'already-claimed' | 'uncertain';

async function sendEmailOnce(
  id: string,
  send: () => Promise<void>,
): Promise<SendOutcome> {
  if (!(await claimEmail(id))) return 'already-claimed'; // 别人已认领,或者已经发完了
  try {
    await send();
  } catch (err) {
    if (err instanceof DeliveryUncertain) {
      alertHuman(`email ${id}: outcome unknown, stays claimed, will NOT be resent`);
      return 'uncertain'; // 认领保留:谁都不会重发
    }
    await releaseClaim(id); // 只有确定的「没发出去」才释放认领
    throw err;
  }
  await markEmailSent(id).catch(() =>
    alertHuman(`email ${id} went out but the mark failed: stays claimed, will not be resent`),
  );
  return 'sent';
}

再看一遍这几个分支,每一个都是一次决策:

被拒绝 → 释放认领,下次任务运行时会重试。这是对的,因为我们确定邮件没发出去。

结果不确定 → 保留认领,并通知人工处理。 这封邮件是被故意卡住的。一个月卡住一封邮件的代价很小,重复发一份病历的代价可不小。

已发送但状态没标记成功(发送成功后紧跟着的数据库写入失败)也保留认领。同样一句话:绝不重发。

进程在认领之后、结果落定之前挂掉。 这时候没人在跑,没法通知任何人,就交给恢复任务来做:一条没有结果、且已存在超过几分钟的认领记录,按「不确定」处理。它会被标记出来,永不重发。

用分布式系统的术语说,这叫选择至多一次投递(at-most-once):邮件偶尔可能需要人工介入,但绝不会发两次。相反的选择是至少一次(at-least-once),例如 Chris Richardson 在《微服务模式》(Microservices Patterns)里讲的 transactional outbox 模式,它从不丢消息,但需要接收方去重。而这里的接收方是客户的收件箱——没有幂等键(idempotency key)的话,收件箱可不会帮你做去重。

两个细节可以让需要人工处理的部分更省事。第一,稍后再问服务商。如果你的服务商提供消息事件或日志的 API,恢复任务可以过一阵子去查那封状态不明的邮件,自己把结果定下来:查到了就说明已发送,标记好即可。只有仍然查不到的,才交给人处理。第二,状态不明时不要道歉。当结果不确定时,我的流程也会跳过那封“抱歉,出错了”的邮件。客户可能已经收到了真正的邮件,这时再为一桩也许根本没发生的失败道歉,只会多出一封让人困惑的邮件。

然后把它锁死

当 AI 帮你写代码时,最重要的一点是:写在注释里的规则,AI 迟早会忘。下个月我让它写一个新的邮件流程,助手又会把那个人畜无害的重试循环写出来,看起来还挺像样。所以,规则要变成一条会失败的测试。我在测试里用的假邮件服务商可以模拟真实网络里那些恶心的行为:收下邮件,然后超时。

it('never resends an email whose outcome is uncertain', async () => {
  const provider = fakeProviderThatAcceptsThenTimesOut();
  await sendConfirmation(booking, { provider });
  await runRecoveryJob({ provider }); // the job that picks up "unsent" emails
  await sendConfirmation(booking, { provider }); // and a user clicking twice
  expect(provider.accepted).toHaveLength(1); // one email reached the customer, not three
});

这条测试才是真正的护栏。将来某次改动把幼稚的重试逻辑带回来,测试就会变红,AI 就得解释清楚自己干了什么。现在这个模式我到处都在用:当我发现一个要紧的坑时,它不会写进笔记,而是变成一道会拦路的检查。

规则就一句话

读操作可以重试。写操作只有满足幂等,或者你确定它根本没送到时,才能重试。

这条规则远不止适用于邮件:支付、短信、WhatsApp 消息、你调用的 webhook,任何会改变数据库之外世界的东西,都算在内。

你的 AI 助手知道怎么写重试循环。但它不知道你的哪些错误意味着“我不确定”。你得告诉它,然后还得让测试套件记住这件事。

延伸阅读

本文里的想法并非我原创。

这些内容我是从下面这些资料里学到的,它们讲得比我清楚:Martin Kleppmann 的《Designing Data-Intensive Applications》(O'Reilly,2017):第 8 章讲不可靠网络与超时,第 11、12 章讲幂等与恰好一次处理。Malcolm Featonby 的《Making retries safe with idempotent APIs》,发表于 Amazon Builders' Library。Marc Brooker 的《Timeouts, retries, and backoff with jitter》,发表于 Amazon Builders' Library(2019)。Brandur Leach 的《Implementing Stripe-like Idempotency Keys in Postgres》(brandur.org,2017)。Betsy Beyer、Chris Jones、Jennifer Petoff、Niall Richard Murphy 合编的《Site Reliability Engineering》(O'Reilly,2016):第 22 章《Addressing Cascading Failures》,作者 Mike Ulrich。Chris Richardson 的《Microservices Patterns》(Manning,2018):讲的是事务性发件箱(transactional outbox)。我自己加的其实很少:养成把每条规则都变成测试的习惯,这样帮我写代码的 AI 就没法把它们忘掉。我是 Matteo Mazzocchi,一名意大利开发者。我给小企业做 CRM 和后台系统,身边有一个 AI 搭档,还有一大堆会说“不”的测试。想了解我的工作,可以访问 mazzocchimatteo.com。

我给意大利的小公司做后台系统:CRM、预约流程,还有那些不起眼但必须靠谱的东西——把正确的邮件发给正确的人。这些代码大部分是我和 AI 助手一起写的。我不掩饰这一点,这就是我的工作方式。但有一个 bug,我的助手几乎每次都会写出来,每个项目都有,除非我拦住它。它看起来像很规范的工程做法,测试也都能通过,可一上生产环境,就会给你的客户发两遍同样的邮件。

看起来没问题的代码

你让 AI「发送确认……

查看原文