返回博客

Ali-OSS 默认 30s 超时太激进,三层回退只挡 404 不挡抖动

某个文件预览接口突然开始大面积 500。报错信息直接印在响应体里:

Response timeout for 30000ms, please increase the timeout,
see more details at https://github.com/ali-sdk/ali-oss#responsetimeouterror

奇怪的是,用 curl 直接打 OSS 端点,215ms 就回来了,端点本身没问题。但 Node.js 进程内的 client.get() 挂了整整 30 秒才抛错——网络没断,只是慢。

现象

  • 触发接口:任何调用了 oss.get() / oss.put() 的请求
  • 错误类型:err.name === 'ResponseTimeoutError',状态码 500
  • 频率:不稳定,有时正常,有时连续失败
  • 本地 curl 探测 OSS 可达,DNS 解析返回的却是 198.18.x.x(RFC 2544 测试保留段)——透明代理或 DNS 劫持把流量拖慢了

根因

坑一:ali-oss SDK 6.x/7.x 默认 timeout: 30000(毫秒)

这个 timeout 是"完整请求-响应窗口",不是 TCP 连接超时。公网出口、跨地域链路、VPN 路由、DNS 污染——任何一个环节偶发延迟,30s 就不够用了。到点直接抛 ResponseTimeoutError,不重试,直接 500。

坑二:三层存储回退只认 NoSuchKey,不认超时

服务有一套优雅降级逻辑:OSS 读不到 → 查 MySQL → 读本地磁盘。但这套回退的过滤条件是:

if (!isOssNotFound(err)) throw err  // 只处理 404/NoSuchKey

而超时错误的 err.name'ResponseTimeoutError'err.codeundefined(或反过来,取决于 SDK 版本),和 NoSuchKey 没有任何交集。结果:本地磁盘上躺着一份完整的不可变模板文件,愣是用不上,直接抛给用户一个 500。

坑:超时错误和 404 是两个完全不同的 err 字段

NoSuchKeyerr.codeResponseTimeoutErrorerr.name(或有时也在 err.code,版本不同)。只检查一个字段的 isOssNotFound 判断,对超时错误视而不见,回退逻辑形同虚设。

正解

两处修改配合使用:

修复一:把 timeout 做成环境变量可覆盖,默认抬到 60s

const ossClient = new OSS({
  region: process.env.OSS_REGION,
  accessKeyId: process.env.OSS_ACCESS_KEY_ID,
  accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET,
  bucket: process.env.OSS_BUCKET,
  secure: true,
  // 公网/跨地域链路偶发 >30s,抬到 60s;OSS_TIMEOUT_MS=120000 可按需覆盖
  timeout: Number(process.env.OSS_TIMEOUT_MS) || 60000,
})

修复二:读路径的回退扩展到瞬时网络错误(仅限不可变资产)

function isOssTransient(err) {
  if (!err) return false
  const name = String(err.name || '')
  const code = String(err.code || '')
  if (name === 'ResponseTimeoutError' || name === 'RequestTimeoutError') return true
  if (code === 'ResponseTimeoutError' || code === 'RequestTimeoutError') return true
  if (['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENETUNREACH'].includes(code)) return true
  return false
}
 
export async function readStoredFile(filePath) {
  if (ossConfigured()) {
    try {
      return await readOssFile(filePath)
    } catch (err) {
      // NoSuchKey → 老数据迁移回退;ResponseTimeoutError → 网络抖动回退
      if (!isOssNotFound(err) && !isOssTransient(err)) throw err
      if (isOssTransient(err)) {
        console.warn('[oss] transient error, falling back', { name: err?.name, code: err?.code })
      }
    }
  }
  if (mysqlConfigured()) {
    const data = await readMysqlFile(filePath)
    if (data) return data
  }
  return fs.readFile(filePath)
}
警告:写路径绝对不能加这个回退

如果 OSS 写超时后降级写本地磁盘,多机部署下 B 服务器找不到 A 服务器写的文件,用户拿到 broken link。写路径失败就应该大声报错,让用户重试,而不是悄悄写到本地再藏着。

正解:同时检查 err.name 和 err.code,读路径回退才完整

isOssTransient 同时检查 err.nameerr.code 两个字段,加上 Node.js 底层 socket 错误码(ECONNRESET / ETIMEDOUT),覆盖不同 SDK 版本的差异。读路径回退只对不可变资产开放——模板文件、归档上传件、生成文档——有本地副本则降级服务,无本地副本维持原有 500 行为,不引入数据不一致风险。

验证步骤

  1. 从 Node.js REPL 直接 await client.get('your/key') 并计时——若超 30s 抛 ResponseTimeoutError,诊断确认
  2. 设置 OSS_TIMEOUT_MS=60000(或依赖新默认值),重启服务
  3. 复现原来 500 的接口,观察是否成功或降级到本地(不再是 500)
  4. 查服务日志,确认出现 [oss] transient error, falling back 行——证明新分支在生效

一句话外卖

ali-oss 的 timeout 默认 30s 对公网链路太紧;三层回退若只挡 NoSuchKey 就是只挡文件不存在、不挡网络抖动——超时错误藏在 err.name 里,不在 err.code 里,必须同时检查。