Edited at

Promise をリトライする何かを作った

More than 1 year has passed since last update.

cloudpack あら便利カレンダー 2017 2日目の記事です。


tl;dr

タイトルの通りですが、 retryx というライブラリーを作りました。


経緯と動機


  1. API Gateway + Lambda を使ったシステムで、あるリクエストで発火する function A が30秒(API Gateway のタイムアウト秒数)でレスポンスを返せず、困る。

  2. A を A' と A'' に分割して、 A' は A'' を invoke して即座に API Gateway にレスポンスを返す、 A'' は時間がかかる処理をする、という非同期実行に変更。

  3. それだけだとフロントエンドから長い時間がかかる処理の結果が受け取れないので、 function B を作り A'' の処理結果が S3 に置かれている時だけ 200 OK を返すようにしよう。

  4. フロントエンドは B をハンドラーにした API Gateway のパスをポーリングして、 OK でなければ一定時間待機して再度リクエストするようにすればよくね?

  5. フロントエンドの処理がめんどい(楽に書きたい)

多分似たようなライブラリーは少なからずあると思うけど、せっかく書いたこの処理をもうちょい膨らませてライブラリー化してみたかった。


インストール

$ npm install --save retryx


解説

フローを図示するとこんな感じです。

retryx() はリトライを含んだ全体の処理の Promise を返します。

retryx() の第一引数である main はラップしたい(失敗したらリトライさせたい)処理本体です。 Promise ではなく、 Promise を返す関数を渡す点に注意。

maxTries は読んで字の如く最大試行回数です。デフォルトは5回です。

waiter は再試行前に待機するための関数です。ここも Promise で実装されており、デフォルトではいわゆる exponential backoff に従って試行回数に応じて指数関数的に増える待機時間を待って解決する Promise を返す関数がセットされています。デフォルトでは 100ms → 400ms → 900ms → 1600ms と変化します。5回失敗すると処理自体の時間を除けばちょうど3秒です。

毎回同じ時間待機したい、待機時間にランダム性を加えたい、指数関数的に待機時間を増やすがある程度のところで上限を設けたい、などの要件がある場合はこのオプションにそのように実装した関数を渡せば大丈夫です。

retryCondition は試行回数に関わらず再試行を行うかどうかの判定を行う関数です。デフォルトでは常に true を返すようになっています。

もし main が失敗し、 onRejected() に渡されるエラーがリトライでは解決不可能だったりした場合に、この関数で false を返すと試行回数が最大試行回数に達していなくても retryx() が失敗されます。

beforeTry, afterTry, beforeWait, afterWait, doFinally はデフォルトでは何もしません。もし詳細にログを出力したい、成功しても失敗しても必ず最後に行いたい処理がある、などがあれば使えるかなあと思っています。


使用例

AWS SDK と組み合わせてみた例。 AWS API を叩きまくる人は一度はスロットリングに泣かされたはず。

const retryx = require("retryx");

const AWS = require("aws-sdk");

const ec2 = new AWS.EC2();

retryx(() => ec2.describeRegions().promise()).then(response => {
console.log(response);
});

axios と組み合わせてみた例。

const retryx = require("retryx");

const axios = require("axios");

retryx(() => axios.get("http://example.com")).then(response => {
console.log(response.statusText);
});

もちろん async/await も使えます。

import retryx from "retryx";

(async () => {
try {
const result = await retryx(() => {
const number = Math.round(Math.random() * 100);

if (number > 95) {
return number;
} else {
throw number;
}
});

console.log("success", result);
} catch (n) {
console.log("fail:", n)
}
})();

待機する前にそのことを表示してみる例。ついでに待機時間のアルゴリズムも単純に100ms待つものに。

const retryx = require("retryx");

retryx(() => {
const number = Math.round(Math.random() * 100);
return number > 95 ? Promise.resolve(number) : Promise.reject(number);
}, {
maxTries: 100,
beforeWait: (tries) => console.log(`try #${tries} failed. wait 100 ms`),
waiter: () => new Promise((r) => setTimeout(r, 100)),
}).then(result => {
console.log(`success: ${result}`);
});

TypeScript の型推論が効きます。 VSCode 最高!

import retryx from "retryx";

(async () => {
let result = await retryx(() => 123);
result = "abc"; // ERROR: Type '"abc"' is not assignable to type 'number'.
// `retryx(() => 123)` は `Promise<number>` で、 `await` すると `number` になります
})();

TypeScript のジェネリクスで、最終的に Promise が解決する値の型を明示的に指定できます。

import retryx from "retryx";

(async () => {
let result = await retryx<string>(() => { // 明示的に `string` であると指定
const number = Math.round(Math.random() * 100);

if (number < 50) {
throw new Error();
} else if (number < 80) {
return "good";
} else if (number < 90) {
return "great";
} else {
return number; // ERROR! main Promise が `number` に解決することは上での定義からあり得ないのでコンパイルエラーになる
}
});
})();


さいごに

車輪の再発明楽しい。