どのスクレイパーにもリトライロジックは必要だが、そのほとんどは意図せず育っていく。ここにtryブロック、あそこにtime.sleep、何かが壊れた週に足された試行回数のカウンター、という具合に。それはターゲットの調子が悪い時間帯までは動作するが、その日になるとリトライ経路は元の失敗以上のダメージを与えることになる。なぜそうなるのかについての論拠はretry and backoffにあり、本稿はその実装編である。
軸となる考え方は、リトライとはリクエストをループで包んだものではなく、分類された失敗から下される決定である、というものだ。分類さえ正しくできれば、あとは自然に決まってくる。
まず分類する
すべての失敗は4つのクラスのいずれかに分類され、それぞれに対して正しい対応はただ一つ存在する。
| クラス | 例 | 対応 |
|---|---|---|
| Transient(一時的) | timeout、connection reset、502、503、504 | バックオフしてリトライ |
| Rate(レート) | 429、Retry-Afterが存在 | 指示通りに待機し、同じルートを使う |
| Identity(識別) | チャレンジページ、継続的な403、ブロックページ | 新しいセッションにしてからリトライ |
| Terminal(終端) | 404、400、401、407、パース失敗 | リトライせず、表面化させる |
このうち人がよく間違えるのは2つある。Rateのシグナルはペースに関する指示であるため、対応は住所を切り替えることではなく待つことだ。なぜなら、同じペースを維持するためにローテーションすることこそが、スロットリングをブロックへとエスカレートさせる振る舞いそのものだからだ。Terminalの失敗は決してリトライしてはならない。407は認証情報や不正な形式のユーザー名フラグを意味し、5回目の試行でも同じように誤りとなる。パース失敗はあなたのコードのバグであり、リトライはそれを永遠に忠実に再現するだけだ。
5番目のケースは、ステータスコードにはまったく現れないものだ。一見正常に見えて実はそうではないレスポンスである。チャレンジページ、空の結果セット、あるいは200で返される切り詰められた一覧は識別クラスに属するが、それはあなたが気づいた場合に限られる。つまり何かを判断する前にボディを検証する必要があるということだ。この検証こそがシステム全体の基盤であり、詳細はdetecting blocked or fake contentを参照。
コードでの分類
import random, time, requests
TRANSIENT = {502, 503, 504}
def classify(resp, exc, is_valid):
if exc is not None:
return "transient" # timeout, reset, DNS
if resp.status_code == 429:
return "rate"
if resp.status_code in TRANSIENT:
return "transient"
if resp.status_code in (403, 401) or looks_like_challenge(resp):
return "identity"
if resp.status_code == 200 and not is_valid(resp.text):
return "identity" # soft block: 200 but not our data
if resp.ok:
return "ok"
return "terminal" # 404, 400, 407, everything else
looks_like_challengeはターゲットごとに定義されるもので、通常は短いマーカーのリストだ。captchaスクリプトの参照、既知のインタースティシャルのタイトル、実際のページよりはるかに短いボディなどである。ターゲットごとに一箇所にまとめておき、リトライ経路と監視の両方が同じ定義を使うようにすること。
ジッター付きバックオフと Retry-After の尊重
transientクラスについては、遅延は指数関数的に増加させ、必ずランダム化する必要がある。固定の遅延はワーカーを同期させてしまい、一度に失敗した100件のリクエストが一斉にリトライすることになり、そのバーストがバックオフを乗り越えて生き残ってしまう。
def backoff(attempt, base=1.0, cap=60.0):
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling) # full jitter
rateクラスについては、ターゲットが正確な待機時間を教えてくれる場合があり、その指示は自分のスケジュールに優先する。
def wait_for(resp, attempt):
ra = resp.headers.get("Retry-After")
if ra:
try:
return min(float(ra), 300) # honour it, but cap it
except ValueError:
pass # HTTP-date form, fall through
return backoff(attempt, base=2.0) # rate signals start slower
組み立てる
MAX_ATTEMPTS = 4
def fetch(url, country, is_valid, session_id=None):
sid = session_id
for attempt in range(MAX_ATTEMPTS):
proxies = build_proxies(country, sid) # sid=None means rotate
resp = exc = None
try:
resp = requests.get(url, proxies=proxies, timeout=20)
except requests.RequestException as e:
exc = e
kind = classify(resp, exc, is_valid)
if kind == "ok":
return resp
if kind == "terminal":
raise TerminalError(url, getattr(resp, "status_code", None))
if kind == "identity":
sid = new_session_id() if sid else None # retire the session
time.sleep(backoff(attempt))
elif kind == "rate":
time.sleep(wait_for(resp, attempt)) # wait, do not rotate
else:
time.sleep(backoff(attempt))
raise Exhausted(url)
ここには構造よりも重要な3つの詳細がある。この関数はNoneを返すのではなく終端エラーで例外を発生させるため、呼び出し側が失敗を空のデータと取り違えることはない。識別失敗はセッションを再利用するのではなく置き換える、なぜなら以前のセッションはすでにターゲットに知られているからだ。そしてレート失敗は意図的にセッションに触れず、同じルートを維持したまま速度だけを落とす。
リトライを問題化させないためのガードレール
リクエストごとのロジックだけでは不十分だ。それにはシステム全体を見渡す視点がないからだ。3つの追加要素が保護作業の大半を担う。
リトライ予算は、あるターゲットへの総トラフィックに対するリトライの割合、たとえば10パーセントに上限を設ける。通常運用ではこの上限に近づくことはない。ターゲットが広範囲で壊れた場合、予算は即座に使い果たされ、それ以上のリトライは単純に発生しなくなる。これはまさに望ましい振る舞いだ。リトライは孤立した失敗には役立つが、全体的な障害の最中には積極的に害を与えるからだ。
ターゲットごとのサーキットブレーカーは、失敗率がしきい値を超えた時点で送信を完全に停止し、クールダウンを待ち、その後回復をテストするために少量だけ通過させる。これはターゲットをあなたの集中砲火から守り、また誰にも応答していないサイトに対して失敗を蓄積させないよう、あなたの住所を守る。
**リトライも通過する共有レートリミッター。**リトライがペーシングを迂回してしまうと、エラー経路はターゲットが最も対応できない時に無制限の洪水と化してしまう。すべての試行を同じリミッターに通すこと。詳細はrate limiting and throttlingを参照。
これら3つはすべて、すでにすべてのリクエストを見ている構成要素に属するべきものであり、それがジョブごとのリトライコードではなくプロキシマネージャーを推奨する理由だ。
冪等性とデッドレターキュー
忘れがちな実務上の懸念が2つある。
リトライは、その操作を繰り返しても安全であることを前提としている。収集についてはこれはほぼ常に真である。なぜならページを2回取得してもコストは帯域だけで済むからだ。パイプラインのどこかが取得の副作用として書き込みを行う場合は、その書き込みを何か安定したものをキーとして冪等にすること。そうすればリトライが重複レコードを作ることはない。
そして、あるリクエストが試行回数を使い果たしたとき、それを破棄してはならない。デッドレターキューに送り、現在のバーストの中ではなく、次回の実行時や長いクールダウンの後に、ずっと後になって再処理すること。インシデント中に失敗したほとんどの項目は、1時間後には単独で成功する。キューはハードな失敗を、コストゼロで先送りの失敗へと変える。
リトライ率を監視する
リトライは最も早い警告シグナルだ。リトライ比率、つまりターゲットごとのリクエストに対するリトライの割合は、成功率が下がるよりも先に上昇する。なぜなら、正常に見える結果までリトライでたどり着くパイプラインは、問題を解決しているのではなく隠しているだけだからだ。これは帯域課金の製品においては純粋なコストでもあるため、健全性の指標であると同時に支出の指標でもある。検証済み成功率の隣にダッシュボードとして置くこと。詳細はproxy KPIsとmonitoring your pipelineを参照。
結論
リトライロジックとは、カウンター付きのループではなく、分類器とそれに続く4つの対応である。ボディを検証してソフトブロックが失敗として分類されるようにし、レートシグナルにはローテーションせず待機で対応し、識別失敗ではセッションを引退させ、一時的な失敗にはジッター付きでバックオフし、終端エラーは決してリトライしない。試行回数と遅延に上限を設ける。そして、リクエストごとの視点では提供できないガードレールを加える。広範な障害が洪水にならないようにするリトライ予算、ターゲットごとのサーキットブレーカー、そしてリトライも尊重する共有リミッターだ。使い果たしたリクエストは後の実行のためにデッドレターキューへ送り、何かが変化しつつある最初のシグナルとしてリトライ比率を監視すること。
このフェイルオーバーの片割れ、つまりクリーンなリトライ先をどこに用意するかという問題を提供するのがレジデンシャルプロキシである。実際の家庭グレードの住所からなる大規模なプールにより、引退したセッションは再利用されるのではなく置き換えられる。GB単位の課金により、規律あるリトライは規律のないリトライよりも直接的に安価になる。