⏱️

Fastifyで非同期ハンドラ関数を使おうとしたらだいぶハマった

2023/11/13に公開

はじめに

Fastifyで書かれたソースコードをリファクタリングしていたとき、何らかの手違いによってクライアントへレスポンスを送信できない事態を引き起こしてしまいました。調べてみても対処法がわからず、結局いろいろと検証をする羽目になったのでここにその検証結果を記事として残します。

問題のコード

import Fastify from "fastify";

(async () => {
  const fastify = Fastify();

  fastify.get("/", async (_request, reply) => {
    await reply.header("Content-Type", "text/plain");
    return "Hello, world!";
  });

  const address = await fastify.listen({ port: 3000 });
  console.log(`Server is now listening on ${address}`);
})();

私がリファクタリングしていたコードはもっと複雑で、この最小構成にたどり着くまでにも時間がかかりましたが……。

このようなコードを動かそうとすると、サーバの起動こそうまくいきますが、/へアクセスしても一向にレスポンスが送信されません。何かの計算に時間をかけているような様子でもなく、ただいつまでも待たされます。

どこがいけなかったのか

サーバの起動はできていることからも、ハンドラ関数でのレスポンス生成になにか問題があると考えられます。

fastify.get("/", async (_request, reply) => {
  await reply.header("Content-Type", "text/plain");
  return "Hello, world!";
});

注目してほしいのが2行目、awaitしています。これはESLint(正確にはtypescript-eslint)が、no-floating-promisesルールに基づいて「Promiseは適切にawaitしなよ!」とエラーを表示してきたために書いたものです。

https://typescript-eslint.io/rules/no-floating-promises/

Promiseならばawaitする、当たり前です。FastifyのドキュメントなどではPromiseが返されるとは書かれていませんでしたが、ESLintが言うのだからそうなのでしょう……。しかしこれがよくありませんでした。

reply.header()によって返される値は、正確にはPromiseではなくThenableな値です。Thenableとはその名の通り.then()できる値のことで、Promiseもそうですし、自作クラスもthenメソッドを実装していればThenableです。

class Thenable {
  then(resolve: () => void, _reject?: () => void) {
    resolve();
  }
}

new Thenable(); // ERROR: no-floating-promises

typescript-eslintによるエラーには次のように書かれています。

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the void operator.

提示された選択肢は4つ。

  • awaitする(rejectがエラーとしてthrowされるようにする)
  • .catchする(rejectを適切に処理する)
  • 第2引数付きで.thenする(rejectを適切に処理する)
  • voidする(無視することを明示する)

この中ではawaitが今回の需要と噛み合っていそうだったため、私は深く考えることなくawaitしてしまいました。

というわけで、状況的にこれは「いつまでも履行されることのないThenableの履行を待ってしまった」のが原因であると考えられます。

ハンドラ関数とはそもそもなんなのか

ここで私は、そもそものFastifyのハンドラ関数がどういった挙動をするものなのか整理しようとしました。同期的な関数も非同期的な関数もハンドラ関数として妥当です。返り値はvoidでもそれ以外でも構いませんが、それがクライアントへのレスポンスとして使われるかどうかは場合によります。そもそもレスポンスはどのように組み立てられ、どのタイミングで送信されているのでしょうか?

検証の結果、(真偽はさておき)次のような自然な解釈を得ました。

おそらくFastifyの設計思想として、

  1. リクエストに対してFastifyReplyクラスのインスタンスが自動で作成される
  2. レスポンス関連の処理はFastifyReplyがすべて行う
  3. ハンドラ関数はFastifyReplyを操作する
  4. ハンドラ関数はvoidを返す

というのがあった上で、

  • ハンドラ関数内でawaitを使えるようにするため、ハンドラ関数が非同期関数であることも許容する(返り値がPromise<void>であることを許容する)
  • ハンドラ関数を実行してもFastifyReply.sentfalseならハンドラ関数の返り値をレスポンスとして使う(返り値がunknownPromise<unknown>であることを許容する)

となっているのだろう。

ではここで気になるのが、FastifyReplyがレスポンスを送信するタイミングです。検証のために次のようなコードを書きました。

import { setTimeout } from "node:timers/promises";
import Fastify from "fastify";

void (async () => {
  const fastify = Fastify();

  fastify.get("/", async (_request, reply) => {
    // voidでESLintエラーを無視する
    void reply.header("Content-Type", "text/plain").then(
      () => {
        console.log("resolved");
      },
      () => {} // (第2引数もなぜか必須)
    );

    await setTimeout(100); // 0.1秒だけ待ってみる

    console.log("before send");
    void reply.send("これはレスポンスです");
    console.log("after send");

    await setTimeout(100); // さらに0.1秒だけ待ってみる

    console.log("before return");
    return "これはレスポンスとして使われません";
  });

  const address = await fastify.listen({ port: 3000 });
  console.log(`Server is now listening on ${address}`);
})();

reply.header()の返り値が履行されたタイミング、reply.send()の前後、ハンドラ関数のreturn直前の各部分にconsole.log()を仕込んであります。実行結果は次の通り。

before send
after send
resolved
before return

というわけで、どうやらreply.send()によって履行されるようになっているようです。ここで最初の問題のあるコードを振り返ると……。

fastify.get("/", async (_request, reply) => {
  await reply.header("Content-Type", "text/plain");
  return "Hello, world!";
});

ハンドラ関数が実行され値が返されることで初めてレスポンスは送信されるのに、ハンドラ関数内の処理ではレスポンスの送信を待っている」というデッドロックが起きてしまっています。

どうすればよかったのか

  • awaitしないよう気をつける
  • voidするよう気をつける

おわりに

リファクタリングのついでにFastifyやめたい……。

おまけ:そもそもFastifyのこの挙動はなんなの?(考察)

Fastifyのソースコードを読んで理解できたら良かったのですが、

  • TypeScriptではないため型がわからない
  • JSDocも書かれていないため型がまったくわからない
  • 古典的なCommonJSで書かれており開発ツールの静的解析がうまくいかない
    • そのため、実際の使用方法から型を推測するのも重労働になっている
  • 関係ないけどclassじゃなくprototype拡張なんですね……

といった感じでやる気が尽きて読めなくなってしまったのでわかりません。

FastifyReplyを操作すればいいのかハンドラ関数が値を返せばいいのか、ハンドラ関数が値を返すとしてレスポンスステータスコードの設定やレスポンスヘッダの設定などは依然としてFastifyReplyを操作しなければならないのか、そもそもなぜFastifyReplyは問答無用でThenableなのか(しかもawaitするとデッドロックすることがあるのか)、などなど。わからないことが多いです。

これは完全な想像ですが、Fastifyが生まれた当時の状況として、Promiseがまだ普及しておらず非同期的な処理のほとんどをコールバック関数によって表現していたために、関数の返り値によってレスポンスを変えるような仕組みよりも、FastifyReplyをハンドラ関数に渡してあげてその操作によってレスポンスが送られるような仕組みにしておいたほうがよかったのかもしれません。そして時代の流れに応えようと非同期ハンドラ関数に対応したりしたことで、このようなものになってしまったのではないかと。

Fastifyのドキュメントには、「非同期ハンドラ関数ではreply.send()は使わないでね」とも取れるような記述があります。

https://fastify.dev/docs/latest/Reference/Reply#async-await-and-promises

また、FastifyReplyをThenableにするにあたってされた議論はなかなか白熱しています。

https://github.com/fastify/fastify/issues/1864

私のリファクタリング作業は一体どうしたものでしょうか。できるならFastifyをやめたいですが、Fastifyの資源を活用しまくった既存のコードを他のライブラリを使うように書き換えるのはだいぶ骨が折れそうです。

Discussion