KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

Web Locks API でロック待ちを「しない」選択 — 複数タブにおけるトークン更新競合の解消

こんにちは。カケハシでAI在庫管理の開発をしている江藤です。

AI在庫管理では、複数タブを開いた状態で使われることがあります。認証周りを再実装する中で、複数タブを開いていると発生するアクセストークンの自動更新の重複問題に向き合うことになりました。今回はその問題をWeb Locks APIで解決した実装の変遷を紹介します。

アクセストークン自動更新時の重複

AI在庫管理はSPAで動いているため、アクセストークンの有効期限が切れてしまう数分前に自動でトークンの更新処理を行なっています。 ユーザーが開いているタブが1つだけなら単純に更新リクエストを行うだけで済むのですが、複数タブを開いている場合に少し問題が出てきます。

アクセストークンはリフレッシュトークンを使って更新処理を行いますが、サーバーではセキュリティ向上のためリフレッシュトークンのローテーションを行なっていることが多いです。複数タブで同時に更新処理が走って同じリフレッシュトークンが使われると、最初に成功した1つだけが新しいトークンを受け取り、残りのタブは失効済みのリフレッシュトークンで更新を試みることになります。これだけならクライアント側のリトライでカバーできなくもないのですが、サーバーによってはさらに踏み込んで Reuse Detection(失効済みのリフレッシュトークンが使われたら、そのセッションに紐づくリフレッシュトークンを連鎖的に失効させる)を導入している場合もあります。こうなるとクライアント側でのフォローは難しく、そもそも重複させない制御が必要になります。

sequenceDiagram
    autonumber
    participant A as タブA
    participant B as タブB
    participant S as 認証サーバー

    Note over A,B: 両タブとも同じリフレッシュトークン (RT1) を保持

    par 期限切れ前のタイマーがほぼ同時に発火
            A->>S: POST /token (RT1)
    and
            B->>S: POST /token (RT1)
    end

    Note over S: タブAのリクエストを先に処理<br/>RT1 を失効し、新しい RT2 を発行

    S-->>A: 200 OK (AT2, RT2)
    S-->>B: 401 invalid_grant

    Note over B: タブB はリフレッシュ失敗
    Note over A,S: 【Reuse Detection が有効な場合】<br/>失効済み RT1 の再利用を検知して<br/>セッションに紐づく RT を全て失効

Web Locks API で解決する

そこでWeb Locks APIの出番です。最初は uhyo さんの記事を参考にAbortSignalを用いたロック待ちのタイムアウトありの実装を行なっていました。

const refreshTokenWithLock = async () => {
  const userManager = getUserManager();

  const signinSilentSafely = async (userManager: UserManager) => {
        try {
            await userManager.signinSilent();
            return { status: 'ok' };
        } catch {
            return { status: 'error', withRetry: true };
        }
    };

  // ロック機能が利用できない環境の場合は、ロックを取得せずにトークンのリフレッシュを行う
  if (typeof navigator.locks === 'undefined') {
    return signinSilentSafely(userManager);
  }

  // ロック取得がタイムアウトするまでの時間を5秒に設定
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 5 * 1000);

  try {
    return await navigator.locks.request(
      'token-refresh',
      { signal: controller.signal },
      async () => {
        clearTimeout(timeoutId);

        const user = await userManager.getUser();
        // 有効期限を確認し、別のタブやウィンドウでトークン更新が行われている場合はスキップ
        if (user?.expires_in && 60 * 3 < user.expires_in) {
          return { status: 'ok' };
        }

        return signinSilentSafely(userManager);
      },
    );
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      return { status: 'error', withRetry: true };
    }
    return { status: 'error', withRetry: false };
  }
};

token-refreshという名前のロックを取得し、取得できたタブだけがトークンの更新処理を行います。 AbortSignalに5秒のタイムアウトを設定しているので、ロック取得が長引いた場合はAbortErrorが投げられ、呼び出し側に「リトライ可能なエラー」として通知できるようにしていました。

ロックを取った後にもう一度expires_inを確認しているのは、自分がロックを待っている間に別のタブで更新が完了している可能性があるためです。すでに更新済みなら自分はリフレッシュ処理を呼ぶ必要がないのでスキップしています。

この実装でも機能としてはちゃんと動いていて問題はないのですが、レビュー時にタイムアウトの設定やトークンの有効期限の確認は本当にいるのか?やりたいこと(トークンの更新)に対して補助的なコードが多くてわかりづらくなってないか?という意見が出ました。

「待つ」必要があるのか?を考え直す

ロックを取った後にやっていることは、「別のタブで更新が済んでいるかチェックして、まだなら更新する」だけです。これは言い換えると、「別のタブが更新中なら、自分は何もしなくていい」と等価ではないか?という見方ができます。 (この整理は、更新を担当するタブ側でリトライや最終的な後処理が行われていることが前提です。担当タブが責任を持って後始末してくれるからこそ、他タブは「何もしない」で済んでいます。逆にこの前提が崩れる場合や、他タブの更新失敗に気付かないリスクが仕様として許容できない場合には、素直にロック待ち+タイムアウト方式を取る方がいいと思います。)

この実装の挙動を整理してみると次のようになります。

  • ロックがすぐに取れた(他タブで更新中ではなかった)→ 自タブが更新する
  • 5秒以内にロックを取れた(他タブが更新を終えた直後)→ expires_inが伸びているのでスキップする
  • 5秒待ってもロックが取れなかった(他タブで更新が長引いている)→ withRetry: trueとなり呼び出し元でリトライされる

ロックを取ったあとにも分岐があり、タイムアウト時のリトライ経路もあって一見複雑ですが、突き詰めると「他タブが更新中なら自分は何もしない」「他タブが更新中でないなら自分が更新する」の2択です。だったら待つ必要はなく、「ロックが取れなければ待たずに即スキップする」だけで十分そうです。実はこの挙動を直接表現できるオプションがnavigator.locks.requestに用意されているので、それを使って書き直してみます。

ifAvailable で待たない実装に書き直す

使うのはifAvailable: trueというオプションです。これを指定しておくと、ロックが既に取られている場合に待ち合わせをせず即座にコールバックを呼び出します。その際、引数のlocknullになります。

これを使って書き直すと以下のようになります。

try {
  return await navigator.locks.request('token-refresh', { ifAvailable: true }, async (lock) => {
    // ロックがnullの場合は、他のタブやウィンドウでトークン更新が行われているのでスキップする
    if (lock === null) {
      return { status: 'ok' };
    }
    return await signinSilentSafely(userManager);
  });
} catch {
  return { status: 'error', withRetry: false };
}

ロックが取れた(引数lockがnullではない)場合は、自分が更新の担当タブになってトークンの更新処理を行います。ロックが取れなかった場合は他のタブが更新中なので、何もせず更新処理ができたものとして終了します。他タブの更新が終われば、新しいトークンはlocalStorage経由で自タブからも見える状態になるので、今回のケースでは特別な同期処理は必要ありません。

書き直しの結果、消えたものを並べてみると次のとおりです。

  • AbortControllersetTimeoutによるタイムアウト管理
  • AbortErrorを判定してwithRetryを切り替える例外分岐
  • 「他タブが更新済みか」を推測するexpires_inチェック

最初の実装では合計30行ほどあったロック周りのコードが、ifAvailableを使うことで10行ほどに収まりました。 また、コードを読んだときにトークンの更新処理以外のコードが減り、やりたいことが明確に伝わるようになったと感じています。

おわりに

ロックというと「他の誰かが終わるまで待つもの」と捉えがちですが、今回のように「やる必要があるかどうかの判定」にも使えました。ifAvailableはそういう使い方を素直に書ける便利なオプションだと思います。 似たようなの問題に出会ったとき、いきなり「待たせる」設計に飛びつく前に、そもそも待つ必要があるのか?を一度立ち止まって考えてみると、もっとシンプルな解にたどり着けるかもしれません。