背景
JavaScriptで非同期処理をコールバック関数としてセットしたとき、そのコールバック関数がいつまでたっても呼ばれないケースをケアするために、タイムアウト処理を書きたいことがある。
例:
- HTTPのリクエストを送信したがレスポンスがいつまでたっても返ってこない
- WebSocketで返信を期待して送信したが返信が返ってこない (ping を送ったが pong が返ってこない, etc.)
JavaScriptでは非同期処理をうまく扱うためにPromiseが言語組み込みでサポートされている。
したがって、以下のようなPromiseを作るのが言語の作法としても良さそうだし await
キーワードとも相性がよいだろう。
- 成功したら
fulfilled
になる - 失敗したら
rejected
になる(失敗の原因はタイムアウトに限らないがreason
でわかる)
やりたいこと
今回考えるシナリオの概要は
「Node.js上で、処理を移譲するため子プロセスを起動する。子プロセスの準備ができるまで待ち、準備ができたら実際に通信する」
というものだ。
具体的には以下のとおり。
- Node.jsの親プロセス (Main) が、子プロセス (Sub) を起動する
- Main は、Sub から
Ready
の通知を受け取るまで待つ - Sub は、準備が整ったら Main に
Ready
の通知を送る - Main は、一定時間内に Sub から
Ready
の通知を受け取らなかったら、 Sub の起動に失敗したとみなす (タイムアウト)
子プロセスのラッパーとなるクラスは以下のようなものを想定することにする。
interface ISomeService {
start(): void;
stop(): void;
onReady(callback: () => void): void;
}
onReady
にコールバックを仕掛けておいて start
で子プロセスを起動する。
子プロセスの準備ができたら onReady
に仕掛けておいたコールバックを発火させる。
このインターフェースだけでは start
と stop
しかできずありがたみが無いが、実際は他にも様々な機能がメソッドとして公開されている情景を想像して欲しい。
今時RPC的な呼び出しは珍しいかもしれないが、ありうるシナリオではあると考えている。
トライ1: Promise.race を使う
「Promise タイムアウト」などで検索すると、 Promice.race
を利用する例が多くヒットする。
このアイデアで書いてみる。
const startService = (timeoutSec) => {
const service = new SomeService();
const successPromise = new Promise((resolve, _) => {
service.onReady(() => resolve(service));
service.start();
});
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(`Proc did not launch within ${timeoutSec} seconds.`), timeoutSec * 1000);
});
return Promise.race([successPromise, timeoutPromise]);
};
成功時の Promise とタイムアウト時の Promise を競わせるわけだ。これはよいアイデアだ。
この startService()
を使う時は、以下のようにする。
startService(5)
.then((service) => {
console.log('Proc launched successfully.');
service.xxxxx();
}, () => {
console.log('Attempt to launch proc was timed out.');
});
ダミーのサービスを作って動作確認してみた。
Pythonで数秒sleepしてファイルをtouchするだけのサービスと、ファイルが touch されたら onReady
コールバックを実行する JavaScript のラッパークラスがこちら。
https://gist.github.com/kosuke-suzuki/95f951901e71c65e232d5993caa4853e
欠点
タイムアウトしたときにもサービスが起動したままになってしまう。
タイムアウトしたとき、返す Promise 自体は rejected の状態になっているので、呼び出している側で then
の第二引数に指定した関数が実行される。
しかし、 startService
内で successPromise
として定義した処理自体は止まらない。
サービスの起動がタイムアウトしたとき、サービスプロセスは存在するが Ready
状態ではないということになる。このような中途半端なプロセスは殺してしまいたい。タイムアウトとして扱うにも、これを殺したうえで then
の第二引数の関数を実行したいのだ。
トライ2: Promise.race + タイムアウト時の処理
catch
をチェーンしてその中で中途半端なプロセスを殺す処理を書く。Promise.race
が rejected となったときだけこの処理が実行される。
const startService = (timeoutSec) => {
const service = new SomeService();
const successPromise = new Promise((resolve, _) => {
service.onReady(() => resolve(service));
service.start();
});
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(`Proc did not launch within ${timeoutSec} seconds.`), timeoutSec * 1000);
});
return Promise.race([successPromise, timeoutPromise])
.catch((reason) => {
service.stop();
return Promise.reject(reason); // これがないと呼び出し元に返す Promise が fulfilled になってしまう
});
};
注意点としては、エラーを呼び出し元に伝播させるためには、明示的に rejected な Promise を返す必要があること。
これをやらないと、呼び出し元は fulfilled として扱ってしまう。
これで、動作としては期待通りとなる。
欠点
Promise.reject
を明示的に呼び出さないといけないのはまどろっこしく感じる。
そもそも元の Promise コンストラクタに渡す executor の仮引数 resolve, reject
も、それぞれ片方しか活かせていない。
トライ3: Promiseコンストラクタに渡す executor の中で行う
すべてひとつの Promise でやってしまおう、という案である。
タイムアウト時にサービスを止める処理は setTimeout
の中で行う。
タイムアウト処理をセットする、というこのようなユースケースでは setTimeout
という名前はピッタリに思えますね。
const startService = (timeoutSec) => {
const service = new SomeService();
return new Promise((resolve, reject) => {
service.onReady(() => {
clearTimeout(onTimedout); // 成功時は監視を止める必要あり
resolve(service);
});
service.start();
const onTimedout = setTimeout(() => {
service.stop();
reject(`Proc did not launch within ${timeoutSec} seconds.`);
}, timeoutSec * 1000);
});
};
これで Promise としてはひとつに集約される。
今度の注意点としては、サービスが正常に起動できた場合、タイムアウト監視を止めなければならない点である。これをしないと、サービスが起動して呼び出し元に返す Promise も fulfilled になっているにもかかわらず、タイムアウト監視によってサービスが止められてしまう。
欠点
タイムアウト監視を止めるコードを書き忘れると、成功時もサービスが止まる。
成功時に失敗時のケアをしないといけないのはイマイチな感じがする。
まとめ
function(timeoutSec) {
const successPromise = new Promise((resolve, _) => {
// 成功時の処理のなかで resolve()
// 処理開始
});
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(`Timeout: ${timeoutSec} seconds.`), timeoutSec * 1000);
});
return Promise.race([successPromise, timeoutPromise])
.catch((reason) => {
// Timeoutの後処理
return Promise.reject(reason);
});
}
もしくは
function(timeoutSec) => {
return new Promise((resolve, reject) => {
// 成功時の処理のなかで clearTimeout(onTimedout); resolve();
// 処理開始
const onTimedout = setTimeout(() => {
// Timeoutの後処理
reject(`Timeout: ${timeoutSec} seconds.`);
}, timeoutSec * 1000);
});
};
Web API では
XMLHttpRequest
XMLHttpRequest
は timeout
プロパティ、 ontimeout
イベントハンドラを指定できる。
https://developer.mozilla.org/docs/Web/API/XMLHttpRequest
したがってこのような感じになるだろう(動作未確認)。
const request = (timeout) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.timeout = timeout * 1000;
xhr.ontimeout = () => reject(`Request was not responded within ${timeout} sec.`);
xhr.onload = () => resolve(xhr.response);
xhr.send();
});
};
fetch
Fetch API は、組み込みで Promise を返してくれる使い勝手のよいAPIなのだが、なんとタイムアウトをサポートしていない!こんなことってありますか?
https://developer.mozilla.org/docs/Web/API/Fetch_API
見ると、 AbortSignal という実験的なAPIを使えばタイムアウトも書けるようだ。
https://developer.mozilla.org/docs/Web/API/AbortSignal
こんな感じだろうか(動作未確認)。
const fetchWithin = (url, timeoutSec) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutSec * 1000);
return fetch(url, { signal: controller.signal });
};