画像生成APIは、通常のフォーム送信より長く実行されます。リクエストがタイムアウトしたとき、「生成に失敗した」とは限りません。サーバーではジョブが作成済みで、レスポンスだけが失われた可能性もあります。
ここで同じ生成リクエストをすぐ再送すると、ジョブやクレジット消費が重複するおそれがあります。本稿では、image-to-imageのWeb UIを想定し、TypeScriptで安全にジョブ状態を追跡する設計をまとめます。コードは一般的な実装例であり、特定サービスの内部コードではありません。
ジョブ状態を判別可能なUnionにする
まず、loading: booleanではなく、APIの状態を判別可能なUnionで表します。
type JobState =
| { type: "queued"; jobId: string }
| { type: "processing"; jobId: string; progress?: number }
| { type: "reviewing"; jobId: string; resultUrl: string }
| { type: "completed"; jobId: string; resultUrl: string }
| { type: "failed"; jobId: string; code: string; retryable: boolean }
| { type: "unknown"; jobId: string; reason: "network" | "timeout" };
unknownは重要です。クライアントが状態を取得できないことと、サーバー上のジョブが失敗したことを分離できます。
reviewingも独立させます。モデルがファイルを返しても、人物、商品、文字、境界、光、構図が意図どおりとは限りません。システム上の成功を、そのままユーザーの受け入れ完了にしないための状態です。
ポーリング関数はキャンセル可能にする
画面遷移や新しいジョブの開始後も古いタイマーが残ると、別ジョブの結果でUIを上書きする可能性があります。AbortSignalを受け取り、待機中もキャンセルできるようにします。
const wait = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
window.clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
async function pollJob(
jobId: string,
signal: AbortSignal,
onState: (state: JobState) => void,
) {
let delay = 1000;
while (!signal.aborted) {
try {
const response = await fetch(`/api/jobs/${jobId}`, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const state = await response.json() as JobState;
onState(state);
if (["reviewing", "completed", "failed"].includes(state.type)) return;
delay = Math.min(Math.round(delay * 1.6), 10000);
} catch (error) {
if (signal.aborted) return;
onState({ type: "unknown", jobId, reason: "network" });
delay = Math.min(Math.round(delay * 2), 15000);
}
await wait(delay, signal);
}
}
実運用ではランダムなjitterも加えます。多数のクライアントが同時に再接続したとき、同じ秒に問い合わせが集中するのを避けるためです。
Reactではeffectのcleanupで古い監視を止める
useEffect(() => {
if (!jobId) return;
const controller = new AbortController();
void pollJob(jobId, controller.signal, setJobState);
return () => controller.abort();
}, [jobId]);
依存対象はジョブIDです。フォームのプロンプトやモデル選択を依存配列に入れると、編集中の変更だけでポーリングが再起動します。実行中ジョブは、送信時に固定したリクエストスナップショットと結び付けます。
タブが非表示なら頻度を下げる
画像生成中に別タブへ移動する利用者は多いため、document.visibilityStateを使って非表示中の問い合わせ間隔を伸ばせます。ただし、非表示になった瞬間に監視情報を破棄してはいけません。
ジョブIDをsession storageやサーバー側のユーザーセッションに保存し、画面再読み込み後も同じジョブを再取得できるようにします。保存するのは追跡に必要な識別子であり、期限付きの結果URLを永続的なジョブIDの代わりに使わないようにします。
作成APIには冪等キーを付ける
ポーリングが安全でも、ジョブ作成が重複すれば意味がありません。ユーザーが生成を確定した時点で冪等キーを作成し、同じリクエストの再送では既存のジョブIDを返します。
const response = await fetch("/api/jobs", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(requestSnapshot),
});
HTTP応答が失われても、同じキーを使います。プロンプト、参照画像、モデル、アスペクト比、解像度などをユーザーが変更し、明示的に新しい試行を始めるときだけ新しいキーを発行します。
エラーごとに復旧動作を変える
- ネットワークエラー: ジョブIDを保持し、状態を再取得する
- 能力バージョン競合: 最新設定を取得し、画像とプロンプトを保持する
- 認証・クレジット不足: キュー投入前に止め、復帰後に再検証する
- モデル失敗: 安全に表示できる分類を示し、自動再実行しない
- 結果URLの期限切れ: アクセス認可を更新し、画像を再生成しない
- 品質不合格: 元のスナップショットを残して新しい試行を作る
クライアント側の一定時間経過は「失敗」ではなく「現在確認できない」と扱います。サーバー側の終端状態だけがジョブ失敗の根拠です。
製品制約を状態設計に反映する
具体例として参照したImage to Image Generatorでは、単一画像モードはJPEG・PNG・WebPの1枚、複数画像合成は2〜5枚を入力します。公開UI上の上限は1ファイル24MB、プロンプトは1000文字です。モデル、アスペクト比、解像度、ログイン要件、クレジットコスト、可用性は変わる可能性があり、4Kは対応するワークフローに限られます。
また、処理時間、完全な変換、人物同一性、欠陥のない出力、独自性、第三者権利のクリアランスは保証できません。したがって、ポーリングの終点を単なる「ファイル取得」にせず、結果確認へ接続する必要があります。
設計の背景にした自作ツールはImage to Image Generatorです。再利用できる要点は、タイムアウトと失敗を分け、ジョブIDと不変スナップショットを保持し、監視をキャンセル可能にすることです。
