こんにちは!
ビジネスアクセラレーション事業部の大瀧優杏です🎀

前回、BFF=Best Friend Foreverだと思ってた話という記事を書きました。

ありがたいことに、読んでくださった方から「その内容でLTしてほしい!」とリクエストをいただき、発表用に内容をアップデートしました👏🏻

今回はそのアップデート版を記事にまとめましたので、ぜひ最後まで読んでいただけたら嬉しいです🥰

前回のおさらい

BFF=Best Friend Foreverではなく、Backend For Frontend

前回のBFFは、こんな感じで2つのAPIを並列に叩いていました。

app.get("/api/users/:id/dashboard", async (req, res) => {
  try {
    // 2つのAPIを裏で並列に叩く
    const [userRes, postsRes] = await Promise.all([
      fetch(`${BASE}/users/${id}`),
      fetch(`${BASE}/posts?userId=${id}`),
    ]);
    // 整形して返す(over-fetch対策)
    res.json({ /* ... */ });
  } catch (err) {
    res.status(502).json({ error: "upstream error" });
  }
});

Promise.all

引数として受け取ったPromise(今回は2つのfetch)がすべて成功したら結果値の配列を返し、1つでも失敗したら残りの結果を待たずにすぐエラー処理に移ります

→ユーザーAPIと投稿APIを同時に叩いて、両方そろったら返却

逆にいうと、

Promise.allは、1本でも失敗した場合、残りを待たずに全体を失敗として返却

つまり、ユーザー情報はちゃんと取れていても、投稿APIがこけると catch に落ちて、まるごと502です。

BFFは複数APIを「まとめる」のが、便利なポイントなのに、

叩くAPIが増えるほど、全体がこける確率が掛け算で上がっていきます。💦

便利だけど、まとめるほど脆くなっちゃうんですね ><

そこで、

allSettled で壊れ方を設計

Promise.all → Promise.allSettled

変更点は上記の一点です。

Promise.allSettled 

引数として受け取ったすべてのPromiseの成功もしくは失敗に関係なく最後まで待機し、それぞれのPromiseの結果を記述したオブジェクトを配列として返します。

const [userR, postsR] = await Promise.allSettled([
  fetchJson(`${BASE}/users/${id}`),        // コア
  fetchJson(`${BASE}/posts?userId=${id}`), // 付加
]);

// user はコア:落ちたらエラーで返す
if (userR.status === "rejected") {
  const status = userR.reason?.status === 404 ? 404 : 502;
  return res.status(status).json({ error: "core upstream failed" });
}
const user = userR.value.data;

// posts は付加:落ちてもページは出す
const postsOk = postsR.status === "fulfilled";
const posts = postsOk ? postsR.value.data : [];

user API:これが無いとマイページが成立しない
→ ダメなら潔く502で諦める
posts API:user APIの付加情報。なくても画面は出せる
→ 落ちても空で続行

投稿APIが落ちても、ユーザーAPIの情報だけでページが出るよう設計しました🫶🏻

レイテンシ予算とタイムアウト

fetchはリクエストが返ってくるまで待ち続けます。
BFFは複数APIを束ねるので遅い1本があると、それが返るまでページ全体が停止してしまいます💦

そこで重要になるのがレイテンシ予算の考え方です!

レイテンシ予算って?

処理を完了するために許容される全体の最大遅延時間のことです。
今回は1.5秒に設定しました。

このレイテンシ予算を厳守するための手段として、 AbortControllersetTimeoutを導入します!

AbortController

→実行中の非同期処理を外部から中断する仕組み

const controller = new AbortController()で作成

controller.abort(): 中断信号を送信

controller.signal: fetch等に渡す中断検知用シグナル

setTimeout

→指定した時間が経過した後に、特定の動作をさせることができる関数
この2つを組み合わせて、「1.5秒経過したら自動で fetch を中断する」処理を記述します。

async function fetchJson(url, { timeoutMs = UPSTREAM_TIMEOUT_MS } = {}) {
  // ① 中断リモコンを用意
  const ctrl = new AbortController();
  // ② timeoutMs 後に「中断ボタン」を自動で押す予約
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);

  try {
    // ③ signal を渡す → 時間切れで fetch が自分から止まる
    const res = await fetch(url, { signal: ctrl.signal });
    if (!res.ok) throw Object.assign(new Error(`upstream ${res.status}`), { status: res.status });
    return { data: await res.json() };
  } catch (err) {
    if (err.name === "AbortError") throw new Error("upstream timeout");
    throw err;
  } finally {
    clearTimeout(timer); // 間に合ったら予約キャンセル
  }
}

指定されたtimeoutMs(1.5秒)が経過した時点で ctrl.abort() が実行され、遅延が発生しても安全に処理を打ち切れるよう設定しました✨

検証

API取得に失敗した場合

投稿API
実際にAPIを落とすことはできないので、コード上で擬似障害の注入を行い、擬似的にAPIが落ちた時を想定しています。

投稿APIの取得に失敗しているという旨のバナーが出ていますが、ユーザーAPIの情報が表示されています!

開発者ツールで確認すると、、、

Headers
🟢200 OK

Response

"user": {
                "ok": true,
                "tookMs": 42
            },
            "posts": {
                "ok": false,
                "error": "ECONNREFUSED"
            }

ユーザーAPIが取得できている一方、投稿APIの取得が失敗していることが確認できます。
フロント上でも、ユーザーAPIの情報は表示できていますが、投稿APIの内容が表示されていません。

ユーザーAPI

開発者ツールで確認すると、、

Headers
🔴502 Bad Gateway

Response

            "user": {
                "ok": false,
                "error": "ECONNREFUSED"
            },
            "posts": {
                "ok": true,
                "tookMs": 200
            }

投稿APIの取得には成功していますが、必須情報であるユーザーAPIで502エラーが発生しているため、フロント上に双方のデータが表示されない状態になっています。

タイムアウトが発生した場合


開発者ツールで確認すると、、

Headers
🟢200 OK

Response

"user": {
                "ok": true,
                "tookMs": 70
            },
            "posts": {
                "ok": false,
                "error": "ETIMEDOUT"
            }

投稿APIがタイムアウトしたため、ユーザーAPIのみ取得・表示されています。

まとめ

BFF = Backend for Frontend

Best friend foreverじゃない🤣

複数のAPIをまとめて叩いてデータを返却するという便利な役割がある一方、まとめるほどシステム全体が脆くなる
という問題がありました。

それを解決するために、

壊れ方を設計する

必要がありました。

私が今回行った対策は下記の2点になります。

Promise.allSettledで全体の結果を待つこと
AbortControllersetTimeoutを組み合わせてタイムアウトを設定すること

最後に

「BFFってなに?」という素朴な疑問から始まったこの検証ですが、
内容をアップデートして第二弾の記事として形にできたことをとても嬉しく感じています。

最後までお読みいただき、ありがとうございました!