取得の速度を上げていくと、いつか 429 が返ってきます。 の 429 は「要求が多すぎる」という意味で、相手が正常に動いていて、こちらを意図的に断っている状態です。500 番台とは意味が違うので、対処も変わります。
この回では、429 が返ったときに何を見て、どれだけ待ち、どう間隔を空けるかを扱います。特に「なぜ待ち時間にゆらぎを足すのか」は、理屈だけだと納得しにくいので実際に数字を出します。
まず Retry-After を見る
429 と一緒に `Retry-After` という が返ることがあります。相手が「これだけ待って」と明示しているので、自前の計算より優先します。秒数で返る場合と、日時で返る場合があるので、両方を受けられるようにしておきます。
見落としやすいのは、日時形式のときにこちらの時計がずれていると待ち時間の計算も狂うことです。数秒のずれなら実害は出ませんが、時計が大きく狂っている環境では負の値になったり、逆に何時間も待つ計算になったりします。下限をゼロで止めておくのが安全です。
from email.utils import parsedate_to_datetimefrom datetime import datetime, timezone
def retry_after_seconds(resp) -> float | None: """Retry-After を秒に直す。秒数形式と HTTP-date 形式の両方に対応。""" raw = resp.headers.get("Retry-After") if not raw: return None raw = raw.strip() if raw.isdigit(): return float(raw) try: when = parsedate_to_datetime(raw) except (TypeError, ValueError): return None if when.tzinfo is None: when = when.replace(tzinfo=timezone.utc) return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())`X-RateLimit-Remaining` のように残り回数を返す があります。ゼロになる前に自分から間隔を空ければ、429 を受けずに済みます。断られてから待つより、断られないように進むほうが速いです。
指数バックオフだけでは足りない
は、待ち時間を 1秒、2秒、4秒、8秒と倍にしていく方式です。混雑しているときに間隔を空けるという意味では正しいのですが、これだけだと足並みが揃ってしまいます。
同時に走っている取得処理が10本あって、全部が同じタイミングで 429 を受けたとします。全部が同じ計算で「1秒待つ」と決めるので、1秒後に10本が同時に再試行します。当然また混雑します。次は全部が2秒待ち、2秒後にまた同時に叩く。間隔は空いているのに、ぶつかり続けます。
だから待ち時間に乱数のゆらぎを足します。これが です。1秒待つところを 0〜1秒のどこかで待つようにすると、足並みが崩れて分散します。処理ごとに違う時刻へばらけるので、相手から見た混雑の山が低くなります。
import random
def backoff_delay(attempt: int, base: float = 1.0, cap: float = 60.0) -> float: """attempt は 0 始まり。0〜(base * 2^attempt) の範囲で待つ(full jitter)。""" window = min(cap, base * (2 ** attempt)) return random.uniform(0.0, window)ゆらぎの入れ方にはいくつか流儀がありますが、上の「0 から上限までの一様乱数」を取る形(full jitter と呼ばれます)が、実装が簡単なわりに散りやすい方式です。
どれくらい違うのか、数えてみる
10本の処理が同時に 429 を受けた状況を、ジッタ有り・無しで比べます。同じ 0.1 秒の枠に何本が集中したかを数えます。
import randomfrom collections import Counter
random.seed(0)WORKERS, TRIALS, ATTEMPT = 10, 2000, 3 # 4回目の再試行(基準 8 秒)
def peak(jitter: bool) -> float: """1試行あたり、同一 0.1 秒枠に集中した最大本数の平均""" total = 0 for _ in range(TRIALS): if jitter: waits = [random.uniform(0, 8.0) for _ in range(WORKERS)] else: waits = [8.0] * WORKERS # 全員が同じ時刻に再試行 buckets = Counter(round(w, 1) for w in waits) total += max(buckets.values()) return total / TRIALS
print(f"ジッタなし: 同時刻に平均 {peak(False):.2f} 本が集中")print(f"ジッタあり: 同時刻に平均 {peak(True):.2f} 本が集中")手元で実行すると、ジッタなしは当然 10.00 本(全員が同じ瞬間)、ジッタありは 1.46 本に下がります。10本の山が、1.5 本に満たない小さな波に崩れるということです。相手から見れば、混雑が一気に来るか、ばらけて来るかの違いになります。
倍々に増やしていくと、10回目には 512 秒になります。上限を置かないと、失敗し続けたときに実質的に止まります。上の例では 60 秒で頭打ちにしています。
そもそも 429 を受けない速度で回す
が「1分あたり60回」と明記されているなら、1秒に1回を超えないように進めれば 429 は起きません。は保険であって、常時それに頼る速度で回すのは、相手に断らせ続けているのと同じです。
- 上限が明記されている → その範囲に収まる間隔で回す
- 残量ヘッダがある → 残りが少なくなったら自分から減速する
- どちらも無い → 直列から始めて、必要になってから上げる
ここまでの話は、相手がレート制限を明示してくれている前提でした。明示が無い配信元のほうが実際には多く、その場合は「どこまでなら許されるか」を探ることになります。探り方としては、直列で回して問題が出ないことを確かめてから、2並列、4並列と、様子を見ながら少しずつ上げていくのが無難です。一度遮断されると、解除の交渉に時間がかかります。
取得が遅いことより、遮断されることのほうが高くつきます。急いで全件取ろうとして遮断されるより、半日かけて確実に取り切るほうが、結果として早く終わることは珍しくありません。次回は認証の型を扱います。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。