先记住这个答案
参数丢失通常出在两个地方。一是复用了已经发过的 Request 对象,它的请求体只能读一次,第二次发会直接抛 TypeError。二是重试时用的 options 被中间环节改过了,比如拦截器往 headers 里塞了东西。所以重试函数要自己保存一份原始的 url 和 options,每次重试都用它们重新调用 fetch;如果拿到的是 Request 对象,就保留原件不动,每次发 request.clone()。
- 保存原始
url和options,每次重新调fetch Request的请求体只能读一次,重发前要clone()fetch遇到500不会拒绝,要自己看res.ok- 主动取消和
4xx不重试,POST 要有幂等保证
一个能用的重试函数
先把重试写对:该重试的重试,不该重试的立刻返回,间隔逐渐拉长。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function fetchWithRetry(url, options = {}, retries = 3) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, options) // 每次都用同一份 url 和 options 重发
// 成功、4xx、或者次数用完,都直接交给调用方
if (res.ok || res.status < 500 || attempt >= retries) return res
} catch (err) {
// 用户主动取消不重试;网络错误在次数用完后才抛出
if (err.name === 'AbortError' || attempt >= retries) throw err
}
await sleep(500 * 2 ** attempt) // 依次等 500、1000、2000 毫秒
}
}有两点容易漏。fetch 只有网络不通、跨域被拦、被取消时才会拒绝,服务端返回 500 时 Promise 是正常 resolve 的,所以要自己检查 res.ok 和 res.status。另外 4xx 通常是请求本身有问题,比如参数错、没权限,重试多少次结果都一样,应该直接返回。
间隔用指数增长,叫指数退避,意思是每失败一次,下次多等一倍。服务端已经过载时,所有客户端都立刻重试只会雪上加霜。
Request 对象为什么不能直接重发
请求体在底层是一个流,发请求时浏览器把它从头读到尾发出去,读完就没有了。Request 对象会记录这件事,发过一次之后 bodyUsed 变成 true。
clone() 的区别JavaScriptconst req = new Request('/api/order', {
method: 'POST',
body: JSON.stringify({ sku: 'A1', count: 1 })
})
await fetch(req.clone()) // 发的是副本,原件的 body 没被读
await fetch(req.clone()) // 重试时再复制一份,照样能发
await fetch(req) // 原件被读掉了
await fetch(req) // TypeError:请求体已经用过
// req.clone() 也不行了,已经用过的 Request 不能再 clone所以正确的顺序是先 clone() 再发,把原件一直留在手里当模板。如果等请求发完再去 clone(),已经来不及了。
- 流式请求体只能自己缓存
- 如果
body本身是ReadableStream,比如边读文件边上传,clone()也只是把同一个流分成两份,内存里会攒下整份数据。这种请求要么在重试前先把内容读成Blob保存,要么干脆不支持自动重试,交给用户手动重传。
重试会不会重复下单
GET 请求重试没什么风险。POST 就要小心:第一次请求其实已经到了服务端并创建了订单,只是响应在路上丢了,前端以为失败又发了一次,结果就有了两个订单。
常见做法是前端在第一次发请求前生成一个唯一 ID,放进请求头,比如很多支付接口约定的 Idempotency-Key,重试时带同一个值。服务端看到重复的 key 就返回上次的结果,而不是再创建一次。这个 ID 必须在重试函数外面生成、和 options 一起保存,否则每次重试都是新 ID,就失去了意义。
容易答错的地方
- 以为
fetch遇到500会进catch - 只有网络层面失败才会拒绝。只在
catch里重试的话,服务端返回500或者503时一次都不会重试。 - 在循环里改
options - 有人在重试前往
options.headers里追加新 token,直接改的是调用方传进来的对象,调用方下次再用这份options时也被污染了。要改就先复制一份,比如{ ...options, headers: new Headers(options.headers) },再在副本上改。 - 用户取消了还在重试
AbortError说明是用户或组件主动放弃的,这时继续重试就违背了取消的本意。catch里要先判断错误名,是取消就直接抛出去。
面试官还会怎么问?
服务端返回 429 要怎么处理?
429 表示请求太频繁,属于 4xx 里少数值得重试的。如果响应带了 Retry-After 头,就按它给的秒数等待,而不是用自己的退避间隔。503 也可能带这个头。
多个客户端同时失败,怎么避免它们同一时刻重试?
在退避间隔上加随机抖动,比如在计算出的等待时间上随机加减一部分。这样大家的重试时间被打散,不会在服务恢复的瞬间一起涌进来。
每次重试要单独设超时吗?
要。整体超时和单次超时是两回事:单次超时防止某一次请求挂住,整体超时限制加上重试一共最多等多久。单次超时可以用 AbortSignal.timeout(ms),再用 AbortSignal.any 和调用方传进来的取消信号合在一起。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。