429 Too Many Requests を返すもの」くらいで止まっていた。なんとも曖昧模糊とした理解である。
ところが公開APIの仕様を眺めていると、429と一緒に Retry-After や RateLimit 系のヘッダーとやらが返ってきている。調べてみると、これがあるとクライアント側の実装がぐっと楽になりそうだ。そこで、自分用のメモとして整理しておく。
429だけでは分からないこと
たとえばAPIを呼び出していて、突然次のようなレスポンスが返ってきたとする。
HTTP/1.1 429 Too Many Requests
HTTPとしてはなにも間違っていない。しかしクライアントから見ると、これだけでは次にどうすればよいのかが分からない。あと何秒待てばよいのか。制限は1分単位なのか、1時間単位なのか。すぐにリトライしてよいのか、今日の上限に達してしまったのか。APIキー単位の制限なのか、ユーザー単位なのか。窓口で「混んでいるので後で」とだけ言われ、何時に来ればいいのか教えてもらえない状態だ。
かくいう僕も、外部APIを叩くときは毎回ドキュメントを読み込んできた。上限を超えないように実装し、その旨を言い訳がましくコメントに書き残す。いささか恥ずかしい実装を、これまで性懲りもなく重ねてきたわけだ。情報のない429にリトライで立ち向かうと、とりあえず5秒待ち、まだ429なら30秒待ち、それでもまだ429で……と、勘と祈りに頼った待ち方にならざるをえない。
社内アプリだけを作っていると、あまり意識しない話である。だが公開APIは、SDK、CLI、バッチ処理と、実にさまざまなクライアントから使われる。そう考えると、ステータスコードだけでなく「次にどう動けばよいか」までレスポンスで伝えることが大事なのだ。
待ち時間を伝えるRetry-After
429を返すとき、まず分かりやすいのがRetry-After だ。
HTTP/1.1 429 Too Many Requests
Retry-After: 60
この場合、クライアントは60秒後に再試行すればよいと判断できる。窓口でいえば、整理券に「60秒後」と書いて渡してくれるようなものだ。値は秒数だけでなく、HTTP-date形式でも返せる。
HTTP/1.1 429 Too Many Requests
Retry-After: Fri, 03 Jul 2026 12:00:00 GMT
API用途なら、個人的には秒数の方が扱いやすいと感じる。読み取って、その秒数だけ眠ればよいからだ。
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
if (response.status === 429 && retryAfter > 0) {
await sleep(retryAfter * 1000);
}
これだけで、クライアントは待ち時間を推測しなくて済む。もっとも、再試行までに1日かかるような仕様なら、タイムスタンプの方がありがたいのかもしれない。
残り回数を伝えるヘッダー
Retry-After は「いつ再試行すればよいか」を伝えるには便利だ。とはいえ、クライアントには他にも知りたいことがある。全体の上限はいくつか。あと何回使えるのか。いつ回復するのか。どの制限に引っかかったのか。こうした情報を伝えるために、公開APIではレートリミット情報をヘッダーで返していることが多い。よく見かけるのは次のような形式だ。
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 300
それぞれのおおよその意味は次のとおり。
| ヘッダー | 意味 |
|---|---|
X-RateLimit-Limit | 利用可能な上限回数 |
X-RateLimit-Remaining | 残り利用可能回数 |
X-RateLimit-Reset | リセットまでの秒数、またはリセット時刻 |
X-RateLimit-* は、サービスごとに意味が微妙に違うこともあるようだ。たとえば Reset が「リセットまでの秒数」なのか「UNIX timestamp」なのかは、APIによって異なる。名前が同じだからとたかをくくらず、使う前にそのAPIのドキュメントを確認する必要がある。
一方、最近の仕様では次のようなヘッダーも定義されているらしい。
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=42;t=30
ざっくり言えば、RateLimit-Policy は「どんな制限ルールか」を、RateLimit は「今どれくらい使えるか」を表すもののようだ。この例なら、q=100 と w=60 で60秒の窓に100リクエストまで、と読める。さらに r=42 で現在あと42リクエスト使え、t=30 でこの窓はあと30秒となる。新しくAPIを設計するなら、既存の慣習と新しい仕様の両方を見ておくのがよさそうだ。
429レスポンスの形
分割ヘッダー方式なら、429のレスポンスはたとえば次のような形になる。
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded.",
"retryAfter": 60
}
RateLimit / RateLimit-Policy を使うなら、次のようになる。
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=60
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded.",
"retryAfter": 60
}
機械的な制御だけなら、ヘッダーで十分だ。しかし、エラー本文のJSONにも同じ情報を含めておけば、UXはさらによくなるはずである。
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=60
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded.",
"retryAfter": 60,
"rateLimit": {
"policy": "default",
"limit": 100,
"remaining": 0,
"resetAfter": 60
}
}
もっとも、ヘッダーとJSONの値がズレていると、かえって混乱を招く。整理券と掲示板で違う時刻が書いてあるようなものだから、そこは気を付けたい。どの形式を採るにせよ、肝心なのはクライアントが「次にどうすればよいか」を判断できることなのだ。
クライアント側でできること
こうした情報が返ってくると、クライアント側でやれることが一気に増える。まずSDKなら、Retry-After を読んで自動でリトライする実装が書きやすくなる。JavaScript SDKなら、たとえば次のような具合だ。
async function requestWithRetry(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, init);
if (response.status !== 429) {
return response;
}
const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
if (retryAfter <= 0) {
throw new Error("Rate limit exceeded.");
}
await sleep(retryAfter * 1000);
return fetch(input, init);
}
実際のSDKでは、最大リトライ回数、指数バックオフ、キャンセル、冪等性なども考える必要がある。それでも、サーバーが Retry-After を返してくれれば、少なくとも「いつ再試行するか」を推測しなくて済む。
次に、管理画面に「API Usage 98 / 100 requests」のような利用状況を出せるようになる。利用者からすれば、あと何回使えるのかが分かるだけで安心感がまるで違う。429になった場合も同じだ。単に「エラーです」と突き放すより、「利用上限に達しました。約60秒後に再試行できます。」と説明できた方がずっと親切である。
CLIツールでも、「Remaining requests: 42」「Reset in: 300s」のように、残りリクエスト数やリセットまでの時間を表示できる。残りが3回ほどになったら「Slowing down to avoid rate limit...」と告げ、処理速度を落とす芸当も可能になる。429で止められる前にペースを調整できるのはありがたい。CI/CDやcronで動くCLIでも、存外役に立つだろう。
極めつけはバッチ処理である。大量のデータを取得するバッチでは、429の扱いが実はかなり重要になる。なんの情報もない429では、どれくらい待てばよいのか見当もつかない。ところが残り回数とリセット時間が分かれば、素直な制御が書ける。残りが0になった時点でリセットまで待機し、前回の続きから再開すればいい。無駄なリトライを延々と繰り返さずに済むので、実行時間やコストの面でも扱いやすくなるわけだ。
実際のAPIと実装時の論点
レートリミットヘッダーは、公開APIではかなり一般的に実装されているようだ。GitHub API、Stripe API、Cloudflare API、Discord API、Shopify API、X API、freee APIなど、名だたるAPIがレートリミット情報をレスポンスで返している。ヘッダー名や値の意味はサービスごとにまちまちだ。それでも「レートリミット情報をレスポンスで返す」という考え方そのものは、もはや常識の範疇と言ってよい。
とはいえ、いざ自分で返すとなると、ヘッダー名のほかにも決めるべきことは枚挙に暇がない。制限はAPIキー単位か、ユーザー単位か、IP単位か。エンドポイントごとに制限が違うのか。Reset は秒数なのか、UNIX timestampなのか。複数の制限がある場合にどれを返すのか。429以外の通常のレスポンスにも残り回数を返すのか。そして、Retry-After と RateLimit の値が矛盾しないようにしなければならない。
とりわけ厄介そうなのが、複数の制限が並存する場合だ。たとえば「1分100回」と「1日10,000回」の両方があるとき、どちらの残り回数を返すのかを決めておかないと、クライアントは判断に困る。RateLimit-Policy / RateLimit のようにポリシー名を含める形式は、このあたりを表現しやすいのかもしれない。
以上、429とレートリミットヘッダーについて整理した。これまで僕は、レートリミットを「制限に達したら429を返す」くらいにしか考えていなかった。だが待ち時間や残り回数を返すだけで、クライアントはいつ再試行すべきか分かり、残り回数を表示でき、無駄なリトライも避けられる。ユーザーへの説明も分かりやすくなる。社内向けのアプリだけを作っていると意識する機会の少ない部分だが、公開APIを設計するなら、ステータスコードの先まで考えておきたい。よいエラーとは、黙って門を閉ざすエラーではなく、整理券を渡してくれるエラーなのだ。