5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

非同期処理のタイムアウト時の処理をPromiseで書く方法の考察

Posted at

背景

JavaScriptで非同期処理をコールバック関数としてセットしたとき、そのコールバック関数がいつまでたっても呼ばれないケースをケアするために、タイムアウト処理を書きたいことがある。

例:

  • HTTPのリクエストを送信したがレスポンスがいつまでたっても返ってこない
  • WebSocketで返信を期待して送信したが返信が返ってこない (ping を送ったが pong が返ってこない, etc.)

JavaScriptでは非同期処理をうまく扱うためにPromiseが言語組み込みでサポートされている。
したがって、以下のようなPromiseを作るのが言語の作法としても良さそうだし await キーワードとも相性がよいだろう。

  • 成功したら fulfilled になる
  • 失敗したら rejected になる(失敗の原因はタイムアウトに限らないが reason でわかる)

やりたいこと

今回考えるシナリオの概要は
「Node.js上で、処理を移譲するため子プロセスを起動する。子プロセスの準備ができるまで待ち、準備ができたら実際に通信する」
というものだ。
具体的には以下のとおり。

  1. Node.jsの親プロセス (Main) が、子プロセス (Sub) を起動する
  2. Main は、Sub から Ready の通知を受け取るまで待つ
  3. Sub は、準備が整ったら Main に Ready の通知を送る
  4. Main は、一定時間内に Sub から Ready の通知を受け取らなかったら、 Sub の起動に失敗したとみなす (タイムアウト)

シーケンス図

子プロセスのラッパーとなるクラスは以下のようなものを想定することにする。

interface ISomeService {
  start(): void;
  stop(): void;
  onReady(callback: () => void): void;
}

onReady にコールバックを仕掛けておいて start で子プロセスを起動する。
子プロセスの準備ができたら onReady に仕掛けておいたコールバックを発火させる。
このインターフェースだけでは startstop しかできずありがたみが無いが、実際は他にも様々な機能がメソッドとして公開されている情景を想像して欲しい。
今時RPC的な呼び出しは珍しいかもしれないが、ありうるシナリオではあると考えている。

トライ1: Promise.race を使う

「Promise タイムアウト」などで検索すると、 Promice.race を利用する例が多くヒットする。
このアイデアで書いてみる。

startService1.js
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 + タイムアウト時の処理

シーケンス図更新。 kill を追加した。
シーケンス図2

catch をチェーンしてその中で中途半端なプロセスを殺す処理を書く。Promise.race が rejected となったときだけこの処理が実行される。

startService2.js
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 という名前はピッタリに思えますね。

startService3.js
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

XMLHttpRequesttimeout プロパティ、 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 });
};
5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?