AI

Workers AI rejectIfBusy入門——混雑時にAI処理をFirestoreへ逃がす

Cloudflare Workers AIのrejectIfBusyを題材に、同期AI処理が混雑したときだけFirestoreへジョブ化し、Next.jsとGitHub Actionsで再実行する設計を解説します。

2026年9月25日
Cloudflare Workers AINext.jsFirestoreGitHub ActionsAI
Workers AI rejectIfBusy入門——混雑時にAI処理をFirestoreへ逃がす

はじめに

AI要約やレビュー機能をWebアプリに組み込んでいると、こんな場面に出会います。

状況困りごと
ユーザー操作の直後にAI要約を返したい混雑時にレスポンスが伸びると画面全体が待たされる
GitHub Issueや問い合わせを一括分類したい同期リクエストがcapacity queueに積まれて処理時間が読みにくい
AIモデルの混雑だけを区別したい通常エラーと再実行すべきエラーを分けづらい

2026年9月17日、Cloudflare Workers AIに rejectIfBusy オプションが追加されました。公式Changelogでは、同期推論リクエストがcapacity queueで待つべきではない場合に、空き容量がなければ即座に失敗させるためのオプションとして紹介されています。

本記事では、架空の問い合わせ管理SaaS「SupportLane」を題材に、Next.js App Router + Firestore + GitHub Actions + TypeScriptで、Workers AIが混雑しているときだけAI処理をジョブ化する流れを作ります。

参考にした一次情報は次の通りです。

rejectIfBusyで何が変わるか

rejectIfBusy は、同期推論を「待つ」か「即時に諦める」かをアプリ側で選ぶためのスイッチです。

Cloudflare公式ドキュメントでは、REST APIの場合はリクエストbodyの options.rejectIfBusy に true を指定します。Workers Bindingの env.AI.run() では、第3引数に { rejectIfBusy: true } を渡します。混雑により拒否された場合は、HTTPステータス 429 と内部エラーコード 3040 が返ると説明されています。

ここで重要なのは、rejectIfBusy が「AIを速くする機能」ではないことです。待ち時間が読めない同期処理を、アプリ側のキューや再実行設計へ逃がすための機能です。

設計方針

SupportLaneでは、問い合わせ本文をAIで要約し、担当者向けの優先度メモを作ります。画面操作の直後に返せるなら同期で返し、Workers AIのcapacity queueに入りそうなら、即座に 202 Accepted を返してバックグラウンド再実行に回します。

ケースAPIの返し方Firestoreの状態
Workers AIが成功200 OKcompleted
Workers AIが混雑202 Acceptedqueued_due_to_capacity
認証や入力が不正400 / 401保存しない
予期しない外部エラー502 Bad Gatewayfailed

ポイントは、混雑を通常の失敗扱いにしないことです。ユーザーには「受付済み」と伝え、担当者画面では「AI要約は準備中」と表示します。

ハンズオン1: Workers AI呼び出しを薄く包む

まず、CloudflareのREST APIを呼び出す関数を用意します。モデル名は公式ドキュメントの例に出ている @cf/google/gemma-4-26b-a4b-it を使います。

type WorkersAiError = {
  code?: number;
  message?: string;
};

type WorkersAiResponse = {
  success?: boolean;
  result?: unknown;
  errors?: WorkersAiError[];
};

export async function runWorkersAiSummary(input: string) {
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
  const token = process.env.CLOUDFLARE_API_TOKEN;

  if (!accountId || !token) {
    throw new Error("Cloudflare credentials are not configured");
  }

  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/google/gemma-4-26b-a4b-it`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        messages: [
          {
            role: "user",
            content: `次の問い合わせを200字以内で要約してください。\n\n${input}`,
          },
        ],
        options: {
          rejectIfBusy: true,
        },
      }),
    },
  );

  const body = (await response.json()) as WorkersAiResponse;
  const isCapacityBusy =
    response.status === 429 && body.errors?.some((error) => error.code === 3040);

  return {
    ok: response.ok,
    status: response.status,
    isCapacityBusy,
    body,
  };
}

公式ドキュメントでは、OpenAI互換Chat Completionsでも options をトップレベルに追加できます。ただし、未知のフィールドを削るクライアントでは適用されない可能性があると説明されています。Next.jsのRoute Handlerから直接 fetch するなら、送信するJSONを自分で管理できるため、この点を見落としにくくなります。

ハンズオン2: Next.jsで混雑時だけジョブ化する

次に、問い合わせ要約APIを作ります。成功時は結果を保存し、混雑時は入力と試行回数をFirestoreへ退避します。

// app/api/tickets/[ticketId]/ai-summary/route.ts
import { getApps, initializeApp } from "firebase-admin/app";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
import { NextRequest, NextResponse } from "next/server";
import { runWorkersAiSummary } from "@/lib/workers-ai";

if (!getApps().length) {
  initializeApp();
}

const db = getFirestore();

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ ticketId: string }> },
) {
  const { ticketId } = await params;
  const { body } = (await request.json()) as { body?: string };

  if (!body || body.length < 20) {
    return NextResponse.json(
      { error: "body must be at least 20 characters" },
      { status: 400 },
    );
  }

  const result = await runWorkersAiSummary(body);
  const summaryRef = db.collection("ticketAiSummaries").doc(ticketId);

  if (result.ok) {
    await summaryRef.set(
      {
        status: "completed",
        provider: "cloudflare-workers-ai",
        rawResult: result.body.result,
        updatedAt: FieldValue.serverTimestamp(),
      },
      { merge: true },
    );

    return NextResponse.json({ status: "completed" });
  }

  if (result.isCapacityBusy) {
    await summaryRef.set(
      {
        status: "queued_due_to_capacity",
        provider: "cloudflare-workers-ai",
        input: body,
        attempts: FieldValue.increment(1),
        updatedAt: FieldValue.serverTimestamp(),
      },
      { merge: true },
    );

    return NextResponse.json({ status: "queued" }, { status: 202 });
  }

  await summaryRef.set(
    {
      status: "failed",
      provider: "cloudflare-workers-ai",
      lastErrorStatus: result.status,
      lastError: result.body.errors ?? [],
      updatedAt: FieldValue.serverTimestamp(),
    },
    { merge: true },
  );

  return NextResponse.json({ error: "AI request failed" }, { status: 502 });
}

この設計では、UIが長時間スピナーを出し続ける必要がありません。202 が返ったら「AI要約を準備中」と表示し、Firestore上の status が completed になったタイミングで結果を出せます。

ハンズオン3: GitHub Actionsで再実行する

最後に、混雑で逃がしたジョブを定期的に再実行します。SupportLaneでは、5分ごとに再実行APIを叩くシンプルな構成にします。

name: Retry queued AI summaries

on:
  schedule:
    - cron: "*/5 * * * *"
  workflow_dispatch:

jobs:
  retry:
    runs-on: ubuntu-latest
    steps:
      - name: Retry queued summaries
        run: |
          curl --fail --request POST "${APP_URL}/api/internal/retry-ai-summaries" \
            --header "Authorization: Bearer ${CRON_SECRET}"
        env:
          APP_URL: ${{ secrets.APP_URL }}
          CRON_SECRET: ${{ secrets.CRON_SECRET }}

再実行API側では、Firestoreの ticketAiSummaries から queued_due_to_capacity を少数ずつ読み、同じ runWorkersAiSummary() を呼びます。大量に読むのではなく、1回あたり3〜5件に絞るのが扱いやすいです。混雑時に全件を再投入すると、また同じcapacity queue問題を作ってしまうからです。

const snapshot = await db
  .collection("ticketAiSummaries")
  .where("status", "==", "queued_due_to_capacity")
  .orderBy("updatedAt", "asc")
  .limit(5)
  .get();

このクエリを使う場合、Firestoreの複合インデックスが必要になることがあります。開発環境でエラーが出たら、Firebaseコンソールが提示するリンクからインデックスを作成しておきます。

運用時の注意点

rejectIfBusy は、すべてのAI処理に付ければよいわけではありません。ユーザーが数秒待てる検索補助や、多少遅くても必ず同期で返したい処理では、通常の待機を選んだ方がUXが自然な場合もあります。

一方で、次のような処理には相性がよいです。

向いている処理理由
問い合わせ要約後から結果が出ても業務が止まりにくい
Issue分類バッチ再実行と相性がよい
管理画面向けのAIメモ生成担当者が画面を再訪したときに表示できる
大量ドキュメントの下書き生成同期レスポンスより監査ログを優先しやすい

また、429 をすべて再実行対象にしないことも大切です。今回の記事では、HTTPステータスに加えて内部コード 3040 を確認しています。レート制限、認証、プラン制約など別の理由で失敗している場合は、別の対応が必要です。

まとめ

Workers AIの rejectIfBusy は、AI機能の信頼性を上げるための小さくて実務的なアップデートです。

  • 同期推論をcapacity queueで待たせたくない場合に使う
  • REST APIでは options.rejectIfBusy、Workers Bindingでは env.AI.run() の第3引数に指定する
  • 混雑拒否は 429 と内部コード 3040 で判定する
  • Next.jsでは 202 Accepted とFirestoreジョブ化を組み合わせると扱いやすい
  • GitHub Actionsのcronで少量ずつ再実行すると、UIと運用の両方が安定する

AI機能は、モデルの精度だけでなく「混んでいるときにどう振る舞うか」で体験が大きく変わります。待たせるべき処理と逃がすべき処理を分けておくと、プロダクト全体の手触りがぐっと落ち着きます。