一天夜里,一个用于产品信息自动补全的定时任务悄无声息地丢掉了大约300行数据。没有报错,没有告警,第二天查看时才发现部分记录空了。回溯日志才看到,上游第三方API在某次请求中返回了429状态码(请求过多),UrlFetchApp.fetch 随即抛出异常,循环中途停止,而脚本的下一次执行直接从起始位置重新开始——被跳过的那300行,再也没有机会被处理。

这并非个例。几乎所有深度依赖 UrlFetchApp 的集成脚本,迟早都会撞上类似的问题。出错的不是某一个函数设计得不够聪明,而是单点脆弱的执行路径。要堵上这个无声丢失的缺口,需要在四层机制上叠加防护:指数退避、并行限流、死信队列以及运行状态追踪。在原作者公开的代码片段中,详细给出了前两层——带抖动的指数退避和 fetchAll 并行调用,并点出了配额墙的真实样貌。

打开网易新闻 查看精彩图片

首先看 UrlFetchApp 的硬配额。对个人消费者账户,每天允许发起 20,000 次 HTTP 调用;如果是 Google Workspace 账户,配额提升到每天 100,000 次。每次调用最大超时为 60 秒,请求与响应的体积上限均为 50 MB。还有一个容易踩坑的隐性限制:单个脚本实例的并发请求数量约为 10 到 20 个(官方并未明确记录),一旦超过这一并发天花板,就会抛出 "Service invoked too many times" 的错误,天真地用大量并行请求来提速只会适得其反。简单算一笔账:如果一次任务要处理 5,000 个 SKU,每个 SKU 需要 4 次补全调用,总调用次数就达到 20,000 次——恰好是用完一整天的调用预算,没有任何容错空间。

第一层防护是带抖动的指数退避,也是整个重试策略的基石。最关键的一行代码是 `options.muteHttpExceptions = true;`。如果没有这行设置,UrlFetchApp 一遇到 429 或 5xx 就会自动抛出异常,根本不会把响应对象交给开发者自定义的重试逻辑,更谈不上检查 HTTP 状态码。正确的做法是主动关闭自动抛异常,拿到响应后手工判定:只有 429 和 5xx 类错误才允许进入重试;对于 400、401 这类客户端错误,重试无法自行修复,必须立即上抛。

指数退避的延时计算很简单:第 n 次重试前等待 2^(n-1) 秒,从 1 秒、2 秒、4 秒、8 秒一路递增,最多重试 5 次,单次等待最长不超过 30 秒。为了防止大批脚本实例在相同的时间点同时醒来、再次同步冲击 API,又在每次基础等待上叠加一个 0 到 500 毫秒的随机抖动。源码中用 `Math.pow(2, attempt - 1) * 1000 + Math.random() * 500` 实现了这个逻辑,并配合 `Utilities.sleep(Math.min(delay, 30000))` 上限守护。原作者在交付前针对决策分支做了单元测试,验证 429 会触发重试、404 则直接拒绝,而且每一次实际等待时间都落在预期区间内(基础时长到基础时长+500 毫秒)。

当单次调用不再频繁失败后,第二层防护——批量并行调用——开始发挥作用。有大量互不依赖的独立请求需要发出时,继续使用串行循环不仅慢,而且会白白消耗更多配额预算。UrlFetchApp 提供的 `fetchAll` 方法可以把一批请求统一提交,在底层做更高效的并发调度,并对配额的消耗计算更为友好。不过,并行数仍要限制在 10 到 20 个请求的批次范围内,以避免触发“服务调用次数过多”的隐形限制。实际落地时,可以把待处理的产品 ID 数组按 10 个一组切成小批次,逐一喂给 `fetchAll`,既利用了并发加速,又不越过红线。

原计划中提到的后两层——死信队列与运行状态追踪——虽然公开的代码片段没有展开,但思路已经足够清晰:经过退避重试依然失败的请求,不应当被直接丢弃,而是落盘到一张失败记录表中(即死信队列),由后续的补偿任务统一重放;同时,每次任务执行需要记录进度标记,避免重启后再次从零开始。四层叠加后,瞬时故障就不会演变成