相信小伙伴在业务中肯定有遇到很多无法实时获取的资源/请求,特别是在这大量网络连接的现互联网时代,每个应用程序和网站都有无数的网络请求。在应用程序或者计算机内部中的 I/O 读写,应用程序之间的调用也都有可能会遇到调用失败需要重新调用,又或者原有调用或请求是不带返回结果的需要不断的去执行某个逻辑来判断是否调用成功。在这种情况下自动重试对用户或者业务来说就是最好的方式。

除了上面我说的“自动重试”外还有用户手动重试,显然对于一个网络请求失败这种作为开发者的我们来说可能都已经很常见了,很多时候的处理逻辑都不能假定一个网络请求或者功能就一定会成功,当然处理错误的方式有很多,除了重试之外还有错误提示 (其实本质上也是希望告知用户操作是失败的,需要用户自己选择和处理是否重试的一种),另外还有一种做法就是有意识或无意识的忽略错误。

当然了,有意识或无意识的忽略错误可以说是一个产品设计策略 (当你的产品抛出一个用户都不知道该如何处理的错误时,你或许就应该考虑是否确实有必要将这个错误抛给用户了),相反则是如果有一个操作对于用户来说是可能执行失败的,在应用程序中没有将执行成功或者失败的结果告知用户也是一种糟糕的产品设计。

如何在 JavaScript/Typescript 中实现指数回退

先来看看指数回退的实现,以及实现和其优劣势,下列代码就是在 ts 中实现指数回退的一种方式

type SyncRetryTaskOption = {
  maxRetries?: number // 最大重试次数
  retryInterval?: number // 重试间隔
}

async function newSyncRetryTask<T>(handle: () => Promise<T>, option: SyncRetryTaskOption | undefined = undefined) {
  const retryInterval = option?.retryInterval ?? 1000
  const maxRetries = option?.maxRetries ?? 5
  let retryCount = 0 // 重试计数
  let retryTaskId: NodeJS.Timeout
  const cleanRetryTask = () => clearTimeout(retryTaskId)
  return await new Promise<T>((res, rej) => {
    const taskFun = (handle: () => Promise<T>, IIFE: boolean = false) => {
      // 清理定时任务
      cleanRetryTask()
      retryTaskId = setTimeout(
        async () => {
          try {
            // 执行具体任务
            const value = await handle()
            res(value)
            cleanRetryTask()
            return
          } catch (error) {
            // 任务重试计数
            retryCount++
            if (retryCount > maxRetries - 1) {
              // 超过最大重试次数
              rej(error)
              cleanRetryTask()
              return
            }
            taskFun(handle)
          }
        },
        IIFE ? 0 : retryInterval
      )
    }
    taskFun(handle, true)
  })
}

首先能看到上面实现了一个闭包,在闭包外层声明了 retryInterval、maxRetries、cleanRetryTask 三个常量和 retryCount、retryTaskId 两个变量,其中 retryInterval 表示重试延迟时间、maxRetries 为最大重试次数、cleanRetryTask 则为一个清理定时器的封装函数,retryCount 变量用于记录当前重试次数、retryTaskId 是计时器 id

然后看看闭包函数内的实现,在代码中将具体执行逻辑全部都封装到 taskFun 函数了,在最后立即执行了该函数,并传了个 handle 待重试业务代码函数和 IIFE 立即执行的参数进去。详细看看 taskFun 函数中的实现,首先调用了一次 cleanRetryTask 函数用来清理上次的重试任务避免多个定时重试任务同时存在,调用 setTimeout 来设定一个延迟执行任务在参数这块通过判断是否传参 IIFE 如果传了的话就将时间设置为 0 也就是立即执行否则的话按照 retryInterval 参数值定时固定时间后执行,在 setTimeout 执行函数内使用 try catch 来捕获 handle 函数执行的错误如果没有异常的话通过上层闭包的 resolve 函数直接成功结束 Promise 要是捕获到异常的话首先将重试次数增加一次再判断是否超过最大重试次数,超过最大重试次数的话直接调用 reject 抛出错误异常并清理定时器,如果有错误且没有超过最大重试次数的话递归调用 taskFun 重新设置定时器。

这样一个简单的指数回退就搞定啦,基于上面的指数回退函数实现,其实稍微将 taskFun 改造一下就能够实现抖动退避,当然还有其他退避重试策略建议根据业务逻辑和实际场景使用不同策略(本来想谈谈个人对这些退避策略的一个粗略的理解的,但是懒癌犯了这篇博客都拖了一周了先发了)。

参考文章

https://en.wikipedia.org/wiki/Exponential_backoff

https://aws.amazon.com/cn/blogs/architecture/exponential-backoff-and-jitter