API連携では、一時的に呼び出しが集中したとき、serverが429 Too Many Requestsを返すことがあります。clientが即座に同じrequestを繰り返すと、制限から回復できないだけでなく、再試行そのものが追加負荷になります。
必要なのは「429ならsleepする」という一行の処理ではありません。誰をどの単位で数えるか、いつ再試行できるか、POSTを二重実行しないか、複数workerが同時に再開しないかまで含めたデータ契約です。
この記事では、429、Retry-After、指数バックオフ、jitter、冪等性を一つの再試行設計として整理します。
429が表すもの
RFC 6585では、429を「一定時間内に利用者が送ったrequestが多すぎる」状態として定義しています。responseには状況の説明を含め、再試行までの時間をRetry-Afterで示せます。
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 45
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Request quota for this operation was exceeded.",
"instance": "urn:request:01JAZ8Q2M4",
"limit_scope": "project",
"retryable": true
}
この例では、HTTP statusが機械的な分類、Retry-Afterが待機指示、bodyが原因と問い合わせ用IDを表します。limit_scopeとretryableはAPI独自のextension fieldであり、RFCの共通fieldではありません。
429は、server全体が停止している503 Service Unavailableとは分けます。特定の利用者、credential、project、endpointだけが上限に達したなら429、service全体の一時的な過負荷や保守なら503が候補です。どちらもRetry-Afterを伴う場合がありますが、監視と復旧判断は別にします。
制限policyをデータとして定義する
rate limitは「1分に100回」だけでは説明不足です。少なくとも、数える対象と単位を固定します。
{
"policy_id": "project-read-v1",
"partition": "project_id",
"resource": "/measurements",
"method": "GET",
"quota_unit": "request",
"limit": 120,
"window_seconds": 60,
"burst": 20,
"policy_version": 1
}
| field | type | 意味 |
|---|---|---|
| policy_id | string | policyの安定した識別子 |
| partition | enum | user、project、credentialなどの集計単位 |
| resource | string | 対象endpointまたは操作 |
| quota_unit | enum | request数、処理byte数、同時実行数など |
| limit | integer | window内で許可するquota量 |
| window_seconds | integer | 集計時間 |
| burst | integer | 短時間に許容する追加量 |
| policy_version | integer | 設定変更を追跡する版 |
固定window、sliding window、token bucketなど、計数方式によって境界付近の挙動は変わります。clientはserverのresponseと送信記録を基に流量を抑えます。
RFC 6585は、利用者の識別方法やrequestの数え方を規定していません。API提供側が、認証主体、project、IP address、endpoint、quota costのどれを使うかを別途契約する必要があります。
Retry-Afterは二つの形式を持つ
RFC 9110のRetry-Afterには、待機秒数とHTTP-dateの二つの形式があります。
Retry-After: 120
Retry-After: Thu, 15 Oct 2026 01:30:00 GMT
秒数はresponse受信後に待つ時間です。HTTP-dateは再試行可能な絶対時刻を表すため、clientとserverの時計差、network遅延、時刻同期を考慮します。
client側では次を決めます。
- 負数、壊れた日時、巨大な値をどう扱うか
Retry-Afterがない場合のbackoff規則- 一回の最大待機時間ではなく、処理全体のdeadlineを超えた場合の終了条件
- 待機中のjob状態と
next_attempt_atの保存方法 - process再起動後に再開する時刻
長い待機はmemory上のsleepだけにせず、queueやDBへ次回実行時刻として保存します。
指数バックオフとjitterを組み合わせる
Retry-Afterがない場合、一定間隔で再試行すると、同時に失敗したworkerが同じ周期で再衝突します。試行回数に応じて待機上限を増やす指数バックオフと、待機時刻を分散するjitterを組み合わせます。
試行番号を$n=0,1,2,\ldots$、初期待機を$b$、上限を$c$とすると、full jitterの待機時間$d_n$は次の範囲から選べます。
$$ 0 \leq d_n \leq \min(c, b \cdot 2^n) $$
random値は再試行の集中を避けるためのもので、秘密情報を生成する用途ではありません。最大試行回数だけでなく、最初のrequestからの総経過時間も制限します。
serverがRetry-Afterを返した場合は、その時間より前に再送しません。同じ時刻に多数のclientが再開する可能性があるため、最低待機時間を過ぎた後に小さなjitterを加える設計もあります。ただし、業務deadlineを超えるなら無理に再試行せず、保留または失敗へ移します。
PythonでRetry-Afterを解釈する
次の例は、秒数またはHTTP-dateを解析し、headerがなければfull jitterを計算します。関数は実際にsleepせず、次の処理が保存できる待機秒数を返します。
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import random
class RetryBudgetExceeded(RuntimeError):
pass
def parse_retry_after(
value: str | None,
*,
now: datetime,
) -> float | None:
if value is None:
return None
value = value.strip()
if value.isascii() and value.isdigit():
return float(int(value))
try:
target = parsedate_to_datetime(value)
except (TypeError, ValueError) as exc:
raise ValueError("invalid Retry-After") from exc
if target.tzinfo is None:
raise ValueError("Retry-After date must include timezone")
return max(0.0, (target - now).total_seconds())
def retry_delay_seconds(
retry_after: str | None,
*,
attempt: int,
remaining_budget: float,
now: datetime | None = None,
base_seconds: float = 1.0,
cap_seconds: float = 60.0,
rng=random.random,
) -> float:
if attempt < 0:
raise ValueError("attempt must be zero or greater")
if remaining_budget < 0:
raise RetryBudgetExceeded("retry deadline passed")
current = now or datetime.now(timezone.utc)
server_delay = parse_retry_after(retry_after, now=current)
if server_delay is not None:
delay = server_delay
else:
ceiling = min(cap_seconds, base_seconds * (2 ** attempt))
delay = rng() * ceiling
if delay > remaining_budget:
raise RetryBudgetExceeded("next retry exceeds deadline")
return delay
email.utils.parsedate_to_datetime()でHTTP-dateをdatetimeへ変換できます。解析errorは記録し、既定backoffまたは停止へ移します。
たとえばRetry-After: 45なら45秒、現在時刻の30秒後を示すHTTP-dateなら30秒を返します。headerがなく、attempt=3、base_seconds=1なら、0秒以上8秒以下の範囲から待機時間を選びます。
再試行状態をrecordとして残す
複数workerやprocess再起動へ対応するには、再試行を状態として保存します。
{
"operation_id": "sync_01JAZ8R4K2",
"attempt": 3,
"first_attempt_at": "2026-10-15T01:20:00Z",
"last_status": 429,
"next_attempt_at": "2026-10-15T01:22:45Z",
"idempotency_key": "sync-project-184-r7",
"response_instance": "urn:request:01JAZ8Q2M4",
"state": "waiting"
}
operation_idは内部処理、idempotency_keyは外部APIへ同じ命令であることを伝える値です。attempt、日時、status、次回実行時刻を分ければ、再起動後も同じ状態から再開できます。
workerはnext_attempt_atを過ぎたrecordを条件付きUPDATEで取得し、lease、status、versionで二重実行を防ぎます。
POSTを無条件に再送しない
GET、PUT、DELETEなどHTTP上で冪等と定義されるmethodと、一般的なPOSTでは再送riskが異なります。RFC 9110は、非冪等methodを自動再試行する場合、requestの意味が実際に冪等であることを判断できる仕組みが必要だとしています。
たとえば「解析jobを作成するPOST」を429後に再送するなら、次を契約します。
- clientが安定したidempotency keyを付ける
- serverがkeyとrequest hashを保存する
- 同じkey・同じ内容なら最初の結果を返す
- 同じkey・異なる内容なら競合として拒否する
- keyの有効期間とscopeを文書化する
429を受け取った時点では、通常はそのrequestが実行されていないと期待しますが、gatewayと業務serverが別の場合など、境界の設計によっては断定できません。副作用のある処理は、rate limitだけでなく業務側の冪等性でも守ります。
独自の残量headerを標準と思わない
X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Resetなどは広く見かけますが、値の単位やresetの表現はserviceごとに異なります。秒数、UNIX時刻、日時を同じfield名で返す実装もあり、名前だけで意味を決められません。
2026年10月15日時点では、RateLimitとRateLimit-PolicyはIETFのInternet-Draftです。変更され得るため、確定RFCとして扱わず、利用する版を契約へ記録します。
基本動作は429とRetry-Afterで成立させます。残量情報は送信量を調整するhintであり、成功保証ではありません。
正常系と異常系をテストする
最低限、次のcaseを自動テストへ含めます。
- 429と秒数形式の
Retry-After - 429とHTTP-date形式の
Retry-After Retry-Afterなしで指数バックオフを使う場合- 壊れたheader、過去日時、非常に大きな待機時間
- deadlineを超えるため再試行を停止する場合
- 同じPOSTを同じidempotency keyで再送する場合
- 同じkeyへ異なるbodyを送って拒否される場合
- 複数workerが同じretry recordを取得しようとする場合
- 429の後に成功、4xxの恒久error、5xxの一時errorへ分岐する場合
testでは実時間のsleepを避け、clockとrandom関数を注入します。待機秒数、次回時刻、試行回数、最終状態を決定的に検証できます。
セキュリティと運用
rate limitは認証や認可の代わりではなく、DDoS対策のすべてでもありません。
- API keyだけでなくtenantやprojectのscopeも確認する
- IP addressだけを識別子にすると共有回線の利用者をまとめて制限し得る
- credentialを作り直すだけで制限を回避できない設計にする
- partition keyへemail addressや秘密情報を直接入れない
- responseへ内部構成や他利用者のquotaを出さない
- 429件数、待機時間、成功までの試行数、最終失敗数を監視する
- client側にも最大並列数と送信queueを設ける
多数のworkerがそれぞれ独立にbackoffすると、合計流量を制御できません。共有queue、semaphore、中央のrate limiterなどを使い、system全体の同時実行数を管理します。
CAD・DB・自動化へ再利用する
CAD変換、BOM同期、ファイルupload、大量CSV取込では、一件のrequest costが同じとは限りません。小さなmetadata取得と数GBのfile変換を「1 request」として同じpolicyへ入れると、server負荷とquotaが一致しません。
操作別にquota unit、同時実行数、処理byte数を設計し、clientはjob queueへnext_attempt_atとidempotency keyを保存します。HTTP response、DBのretry record、workerのleaseをoperation_idで接続すれば、障害後も追跡できます。
まとめ
429への対応は、待機処理ではなく、clientとserverが共有する再試行契約です。
- 429と503を制限scopeに応じて分ける
- policyにpartition、resource、quota unit、window、versionを持たせる
Retry-Afterの秒数とHTTP-dateを両方扱う- headerがない場合は指数バックオフとjitterを使う
- 最大試行回数と処理全体のdeadlineを設ける
next_attempt_atを保存し、process再起動後も再開できるようにする- POSTの再送はidempotency keyとrequest hashで守る
- service固有の残量headerは意味と単位を契約する
- 複数workerの合計流量を共有queueや同時実行制御で抑える
この構造なら、APIが一時的に制限されても再試行が追加障害にならず、DB、CAD、ファイル連携、自動化処理を同じoperation IDで安全に再開できます。
