202 Acceptedが始める契約
一言でいうと
202は「やった」ではなく「受け取った」です。したがって、202を返すAPIは、必ず「どこで結果を確認するのか」を一緒に知らせる必要があります。
なぜ必要なのか
30秒かかる作業を同期APIで作ると、3つが同時に壊れます。ロードバランサーのアイドルタイムアウト(たいてい60秒)にかかり、クライアントがリトライすると同じ作業が2回動き、サーバーのワーカーが30秒ずつ塞がります。
非同期APIは、これを2段階に分けます。受付と確認です。受付はすぐに終わって識別子を返します。確認は、その識別子で状態を問い合わせます。
どう動くのか
HTTPには、このパターンのための語彙がすでにあります。
受付の応答は202 Acceptedで、Locationヘッダーに状態取得のURLを入れます。本文には、ジョブの識別子と現在の状態を入れます。この3つがないと、クライアントは何をすべきかわかりません。
状態の取得は、200で現在の状態を返します。状態の語彙は、単純なほどよいです。queued、running、succeeded、failedくらいです。まだ進行中なら、Retry-Afterで次のポーリングの時点を提案します。これがないと、クライアントがそれぞれ1秒ごとにポーリングして、状態APIが新しいボトルネックになります。
完了したら、結果をどう渡すかは2つの流れがあります。状態応答の本文に結果も一緒に入れるか、303 See Otherで結果リソースのURLを指すかです。後者がRESTらしいですが、クライアントの実装が増えます。結果が小さければ、前者で十分です。
提出も冪等である必要があります。クライアントが202を受け取る前に接続が切れてリトライすると、同じジョブが2つ作られます。提出に冪等キーを要求し、同じキーには同じjob idを返せばよいのです。
Webhookは、ポーリングの代替です。完了時にサーバーがクライアントのURLにPOSTします。遅延がなく、ポーリングの負荷もありませんが、代償があります。クライアントが公開エンドポイントを持つ必要があり、そのエンドポイントが死んでいることもありうるので、サーバーがリトライとDLQを備える必要があり、なりすましを防ぐために署名が必要です。実務では、Webhookとポーリングを一緒に提供する場合が多いです。
現場での姿
最もよくある設計のミスは、job idとして、本当の結果リソースのIDをそのまま使うことです。まだ作られていないリソースのIDを先に渡すということなので、失敗したとき、そのIDは永遠に幽霊になります。job idとresult idは、分けるほうが安全です。
2つ目。ジョブの状態を永遠に保管しないでください。完了したジョブの状態にTTLを置き、期限切れになった取得には404ではなく410 Goneを返せば、クライアントは「なかったもの」と「過ぎたもの」を区別できます。
202を返したあとの契約
202 Acceptedは「受け取った」にすぎず、「実行される」ではありません。そのため、3つを一緒に渡して初めて、クライアントがコードを書けます。
HTTP/1.1 202 Accepted
Location: /api/jobs/7f3a-91cd
Retry-After: 3
Content-Type: application/json
{"job_id": "7f3a-91cd", "status": "queued",
"poll_url": "/api/jobs/7f3a-91cd", "estimated_sec": 30}
Location: どこに問い合わせればよいか。標準のヘッダーなので、ツールが理解します。Retry-After: どれくらい後に問い合わせるか。なければ、クライアントが毎秒10回ずつ叩きます。- ジョブID: リトライしても同じジョブだとわかる鍵。
状態の取得では、最低4つの状態を区別します。
{"status": "queued"} → 아직 시작 안 함
{"status": "running", "progress": 0.4} → 진행 중
{"status": "succeeded", "result_url": "/files/…"} → 결과가 있다
{"status": "failed", "error": {"code": "…","message": "…"}, "retriable": false}
retriableが重要です。クライアントが、もう一度送るのか、人に尋ねるのかを、
この値で決めます。
ポーリングの代わりに通知
ポーリングは単純ですが、無駄です。3つの代替があり、それぞれ価値が違います。
| 方式 | サーバーの負担 | クライアントの複雑さ | ファイアウォール |
|---|---|---|---|
| ポーリング | リクエスト数に比例 | 最も単純 | 問題なし |
| ロングポーリング | 接続を維持 | 単純 | プロキシのタイムアウトに注意 |
| SSE | 接続を維持 | 普通 | たいてい通過する |
| Webhook | 最も少ない | 受信エンドポイントが必要 | 相手が公開アドレスである必要がある |
Webhookを使うなら、リトライと署名を一緒に設計する必要があります。受け取る側が一時的に死んだら、 再送する必要があり、なりすましを防ぐには、本文にHMAC署名を付ける必要があります。
冪等キーで二重提出を防ぐ
クライアントが202を受け取る前にタイムアウトになると、同じリクエストをまた送ります。すると、 ジョブが2つできます。
POST /api/jobs
Idempotency-Key: 8f14e45f-ea3b-4d29-9a1c-2b3c4d5e6f70
サーバーはこのキーを保存し、同じキーがまた来たら、新しく作らずに、最初に作ったジョブの 202をそのまま返します。 キーはクライアントが作りますが、リトライの間で変わってはいけないので、 リクエストの内容から決定的に導くか、リクエストの開始時点で一度だけ生成して保管します。
キーの保持期間は、24時間程度が一般的です。それより長く置くとストレージが増え、短いと 遅いリトライを捕まえられません。
次のラボですること
ジョブ提出APIを作って、202とLocationを正確に返し、ワーカーが状態を遷移させ、Retry-Afterでポーリングを調整し、提出を冪等にして、最後に完了Webhookまで付けます。