理解 got 的 retry、beforeRetry、afterResponse 的 retry()

日期:2022-03-27

作者:科技微讯

got 是一个广受欢迎的 npm 包,这篇文章尝试理解 got 的 retrybeforeRetryafterResponseretry() 的行为。截止到本文发布时为止,got 下载量最多的版本是 11.8.3,所以这里说的 got 专指 11.8.3 的 got。

默认的重试行为

当我们直接使用 got(url) 向某个 url 发起请求时,请求可能失败,失败后 got 会重试请求,最多重试 2 次,最后无论成功与否,都会停止发送请求。

之所以会这样,是因为 got 有一个默认的 retry 选项,其值如下:

{
  limit: 2,
  calculateDelay: () => {}, //一个计算重试等待时间的函数
  methods: ["GET", "PUT", "HEAD", "DELETE", "OPTIONS", "TRACE"],
  statusCodes: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524],
  maxRetryAfter: undefined,
  errorCodes: [
    "ETIMEDOUT",
    "ECONNRESET",
    "EADDRINUSE",
    "ECONNREFUSED",
    "EPIPE",
    "ENOTFOUND",
    "ENETUNREACH",
    "EAI_AGAIN",
  ],
};

retry.limit 默认是 2,所以最多重试 2 次。

retry.methods 默认不包括 POST 方法,所以 got.post(url) 失败了不会自动重试。

retry.statusCodes 默认不包括 404、412 等状态码,所以如果服务器返回这些状态码,got 也不会自动重试。

我们可以通过在 beforeRetry 或 beforeRequest 打印日志判断是否发生了重试。比如下面这个例子,从输出的日记来看,虽然最后进入了 catch,但 beforeRetry 里的日志没有打印,意味着 404 没有被重试,如果把链接中的 404 改为 500 就会自动重试。

const url = "https://httpstat.us/404";
got(url, {
  hooks: {
    beforeRequest: [
      (options) => {
        console.log("请求:", options.url.href);
      },
    ],
    beforeRetry: [
      () => {
        console.log("重试");
      },
    ],
  },
})
  .then((res) => {
    console.log("执行完毕:成功");
  })
  .catch((err) => {
    console.log("执行完毕:出错");
  });
/*
请求: https://httpstat.us/404
执行完毕:出错
*/

修改重试行为

两种方法

修改 got 的重试行为有 2 种方法,第一种是修改 retry 选项,第二种是在 afterResponse 中自定义重试行为。

但这两种方法有各自的适用情况,有些情况只能通过修改 retry 选项来修改重试行为,有些情况只能通过 afterResponse 修改重试行为。用 got 官方的话来说,就是:

Only unsuccessful requests are retried. In order to retry successful requests, use an afterResponse hook.

即失败的请求用 retry 重试,成功的请求如果也想重试,用 afterResponse。为什么成功的请求也要重试?

这里说的成功,是指 http 请求本身是成功的。不过请求虽然成功了,服务器返回的内容可能是有问题的,404 就是这么一个例子,虽然进入了 catch,但进入 catch 的参数包括了 response,而这个 response 不是我们期待的 response:

got({
  url: "https://httpstat.us/404",
})
  .then((res) => {
    console.log(res.body);
  })
  .catch((err) => {
    const response = err.response;
    console.log("responde body:", response.body);
    console.log("error message:", err.message);
  });

再比如请求某个 api,我们期待收到:

const result = {
  code: 0,
  data: ["some usefule data"],
};

结果收到:

const result = {
  code: -1,
  data: null,
};

这个请求本身是成功的,但可能因为一些原因,导致 api 没有返回我们期待获得的数据,这时我们可以重新发起请求,也许重新请求就能拿到理想的数据。

正如前面所说的,修改重试行为有 2 种方法。

方法一:options.retry

options.retry 默认不包括 POST 方法,如果你希望当 POST 出现 retry.statusCodesretry.errorCodes 中约定的情况时也发起重试,则需要在 retry.methods 增加 POST 值。

retry.statusCodes 默认值有 10 个,如果返回的 statusCode 不在这个范围内,那请求出错了不会重试。比如对方返回 404,got 默认不会重试。要想让它重试,需要把它加到 retry.statusCodes 中。

retry.limit 默认是 2,你可以把它改为其他数字,但不建议改为 0。因为 retry 默认会对 ETIMEDOUT(超时)、ENOTFOUND(例如断网)等情况进行重试,这些情况根本就没有 response,所以无法通过 afterResponse 触发重试,要重试,只能把 limit 设置为大于 0 的数字。

默认选项下,got 不会马上重试,它会先等 1s 再发起重试,如果需要重试第二次,则再等 2s,即:Math.pow(2, (retryCount - 1))

如果你希望它马上重试,则让 retry.calculateDelay 函数返回 1,如果返回 0,则会取消重试。calculateDelay 的入参包括 retryOptionserror,你可以根据这两个参数获取为什么重试,以及重试时的 options,并据此调整重试等待时间。

随意修改 calculateDelay 可能会带来一些奇怪的重试行为。如果 calculateDelay 总是返回 1,retry 对象中的部分属性会失效,例如 retry.limit 虽然设置为 1,但依然会无限重试,再比如 404 虽然默认不重试,但这样写之后也会无限重试。

await got({
  url: "https://httpstat.us/404",
  retry: {
    limit: 1,
    calculateDelay: (
      attemptCount,
      retryOptions,
      error,
      retryAfter,
      computedValue
    ) => {
      return 1; //不要这样写!
    },
  },
  hooks: {
    beforeRetry: [
      (options, err) => {
        if (err) {
          logger.log(`重试,重试理由 ${err.message}:`, options.url.href);
        } else {
          logger.log(`重试:`, options.url.href);
        }
      },
    ],
  },
});

要取消重试,需要 calculateDelay 返回 0。由于上面的例子 calculateDelay 始终返回 1,相当于告诉 got 永远不要重试。所以如果你想修改 calculateDelay,需要充分利用 calculateDelay 的 5 个参数进行条件判断,什么时候返回 0,什么时候返回 1,什么时候返回一个你需要的等待时间。

方法二:afterResponse

正如前面所说,如果一个 HTTP 请求是成功的,无论返回的数据是不是我们期待的数据,got 默认情况下不会重试。

got 之所以默认不重试 404,是因为 404 其实也是一个成功的 http 请求,服务器通过 404 告诉我们:你请求的资源我们这里没有。不过有时候,404 并不意味着服务器真的没有这个资源,请求一个 API 返回 404 的原因有很多,如果是因为缺少鉴权信息或鉴权信息失效,我们可能希望修改请求内容后重新发起请求。

404 的重试逻辑建议在 afterResponse 中实现。got 的 GitHub 仓库有人讨论怎样实现 404 重试,大家可以去看看。下面是一个简单的实现方法:

const url = "https://httpstat.us/404";
got(url, {
  hooks: {
    afterResponse: [
      (response, retry) => {
        if (response.statusCode === 404) {
          //可以通过 updatedOptions 修改 got 的 options 入参,例如修改 options.headers、options.url
          const updatedOptions = {};
          //返回一个 retry 函数,告诉 got 用新的 options 发起重试
          return retry(updatedOptions);
        } else {
          return response;
        }
      },
    ],
    beforeRetry: [
      () => {
        console.log("重试");
      },
    ],
  },
})
  .then((res) => {
    console.log("成功");
  })
  .catch((err) => {
    console.log("出错", err.message);
  });

got 的官方文档表示,afterResponse 在用来更新请求头中的鉴权信息尤其好用。

值得注意的是,afterResponse 必须返回一个值,你可以根据条件返回 responseretry(),如果忘记返回,可能会出现 TypeError: Cannot destructure property 'statusCode' of 'response' as it is undefined 错误

搭配 options.retry 和 afterResponse

我想重试,问题是我应该把重试条件写在 options.retry 还是写在 afterResponse 里呢?

对于一个甚至都没有返回 response 的请求,重试条件应该写在 options.retry 中,因为没有返回 response 就无法执行 afterResponse hook。

对于成功请求的重试,建议写在 afterResponse。在 afterResponse 发起重试时,要避免重试条件和 options.retry 重叠,如果重叠可能会出现一些意料之外的事情,比如会增加最大重试次数。

比如下面这个例子,got 默认会对 500 发起重试,我们又在 afterResponse 约定对所有 400 以上的 statusCode 发起重试,因为这个 url 始终只能返回 500,结果一直重试,重试了 5 次,重试次数计算:(retry.limit + 1) * 2 - 1

const url = "https://httpstat.us/500";
got(url, {
  hooks: {
    afterResponse: [
      (response, retry) => {
        if (response.statusCode >= 400) {
          return retry({});
        } else {
          return response;
        }
      },
    ],
    beforeRetry: [
      (options, err) => {
        //由 afterResponse 触发的重试也会调用 beforeRetry hook,但这时候 beforeRetry 的入参没有 err 参数
        if (err) {
          console.log("beforeRetry-1");
        } else {
          console.log("beforeRetry-2");
        }
      },
    ],
  },
})
  .then((res) => {
    console.log("成功");
  })
  .catch((err) => {
    console.log("出错");
  });

建议在写 afterResponse hook 之前,先了解清楚所请求的 url 可能会返回哪些值,如果你请求的是 api,那去看看 api 的接入文档,思考一下 api 返回哪些结果是我们期待的结果。在 afterResponse 根据 response 作条件判断,以决定是否需要重试,以及是否需要修改请求内容之后再发起重试。

修改重试请求

有时候,重试不需要修改请求内容,比如针对失败请求的重试:ETIMEDOUT、ENOTFOUND 等。有时候,如果不修改请求内容,可以预测对方会再次返回我们不希望看到的结果,这时应该修改请求内容。

这里说的请求内容主要包括 options.urloptions.headersoptions.body。我们可以在 afterResponse 或者 beforeRetry 中修改这些内容。前面已经有如何在 afterResponse 修改请求 headers 的例子,下面这个例子是在 beforeRetry 修改请求的 url:

const url = "https://httpstat.us/500";
got(url, {
  hooks: {
    beforeRequest: [
      (options) => {
        console.log("请求的 url:", options.url.href);
      },
    ],
    beforeRetry: [
      (options) => {
        if ((options.url.href = "https://httpstat.us/500")) {
          options.url = new URL("https://httpstat.us/200");
        }
      },
    ],
  },
})
  .then((res) => {
    console.log("成功");
  })
  .catch((err) => {
    console.log("出错");
  });

创建 got 实例

我们可以用 got.extend() 创建一个新的拥有自定义选项的 got 实例,后续请求可以直接由该实例发起,这样可以避免每次请求都写一样的代码。

比如定义一个实例 _got,它把超时时间设置为 10s,增加 POST 方法的重试,并在重试时打印重试理由方便后续排错:

const _got = got.extend({
  timeout: 10000,
  retry: {
    methods: ["POST", "GET"],
  },
  hooks: {
    beforeRetry: [
      (options, err) => {
        //afterResponse 触发的重试也会调用 beforeRetry hook,但不会传入 err 参数,所以需要判断
        if (err) {
          console.log("第一个 beforeRetry,重试理由:", err.message);
        }
      },
    ],
  },
});

接下来我们就可以用 _got 发起请求,它的使用方法和原装的 got 完全一样,我们可以根据实际情况再传入其他 hooks(每一个类型的 hook 都是一个数组,可以接收多个 hook),甚至进一步修改 timeout、retry.methods 等选项。

_got({
  url: "https://httpstat.us/408",
  timeout: 3000,
  hooks: {
    beforeRetry: [
      () => {
        console.log("第二个 beforeRetry");
      },
    ],
  },
})
  .then((res) => {
    console.log(res.body);
  })
  .catch((err) => {
    console.log(err.message);
  });

总结

建议保留 got 默认的 retry 条件,由它负责失败的请求,根据实际情况判断是否要增加 POST 重试。然后列举你请求的 url 可能返回哪些值,这些值中,哪些是预料之外的值,然后在 afterResponse 中作条件判断,当发现这些不合理的值时发起重试,要思考怎样重试才能获得期待的值,并相应地修改请求的 options。

为了避免反复写一样的重试逻辑,建议用 extend 创建一个拥有自定义选项的 got 实例。