毎朝cronやsystemd timerで回しているスクレイピングが、ある日 Retry(method_whitelist=...) の行で落ちるようになった。あるいは、相手サイトが一時的に返した429や503で、その日の収集がまるごと空振りになる。どちらも同じ箇所で解決できます。この記事では、urllib3 2系で削除された method_whitelist を allowed_methods・backoff_factor・respect_retry_after_header で書き直します。そのうえで、429・503では待ってから再試行し、相手に負荷もかけないrequestsのセッションを、今の書き方で組み立てます。

urllib3 2系で method_whitelist が通らない:旧コードと今の書き方を並べる

以前はこう書いていた

数年前の記事やAIが出すコードには、次の形がよく残っています。

from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

retry = Retry(
    total=3,
    backoff_factor=1,
    status_forcelist=[500, 502, 503, 504],
    method_whitelist=["GET", "HEAD"],
)

urllib3 2系の環境でこれを動かすと、Retry の生成時点で次のように止まります。

TypeError: Retry.__init__() got an unexpected keyword argument 'method_whitelist'

引数の名前が変わっただけなので、書き換え自体は1行です。ただ、旧コードには 429が再試行の対象に入っていないという問題もあります。サーバーの Retry-After(何秒後に再アクセスしてよいかを伝える応答ヘッダー)を尊重するかどうかも明示されていません。名前を直すついでに、この2点も今の考え方に揃えます。

今はこう書く

from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

retry = Retry(
    total=5,
    backoff_factor=1.0,
    status_forcelist=(429, 500, 502, 503, 504),
    allowed_methods=frozenset({"GET", "HEAD"}),
    respect_retry_after_header=True,
    raise_on_status=False,
)

変更点は次の4つです。

  • method_whitelist → allowed_methods:再試行してよいHTTPメソッドの指定。名前が変わっただけで意味は同じです。
  • import元を urllib3 直接に:requests.packages.urllib3 は互換のために残された別名です。今は urllib3 から直接読み込みます。
  • status_forcelist に429を追加:429は「アクセスが多すぎる」という相手からの合図です。即座に諦めるのではなく、指示どおり待ってから1回だけ取り直す対象にします。
  • respect_retry_after_header=True を明示:既定でも有効ですが、「相手の指定した待ち時間を守る」という意図をコード上に残しておきます。

実行環境と、手元の版の確かめ方

  • Python:3.10以降を想定(python3 -V で確認)
  • パッケージ:pip install "requests>=2.32" "urllib3>=2"

requestsは依存としてurllib3を引き込みます。そのため、どちらの版が入っているかは環境ごとに違います。古いサーバーでは、OSのパッケージ由来の古いurllib3が先に読まれていることもあります。次の2つで、実際に読み込まれる版と、Retry が受け付ける引数を確認してください。

pip show requests urllib3
python3 -c "import urllib3; from urllib3.util.retry import Retry; print(urllib3.__version__); help(Retry.__init__)"

help の出力に allowed_methods があれば今回の書き方が通ります。method_whitelist は2系では一覧に出てきません。

Retryの各オプションが何をしているか:allowed_methods・backoff_factor・respect_retry_after_header

「とりあえずコピーした設定」のまま毎日回すと、相手が落ちているときに連打する設定になっていることがあります。それぞれが何を決めているかを把握しておきます。

オプション

何を決めるか

今回の値と理由

total

再試行回数の総上限

5。下の個別上限より大きくして、全体の歯止めとして使う

connect / read / status

接続失敗・読み込み失敗・ステータスコードごとの上限

種類ごとに絞る。ステータス起因(429・503など)は多めに、読み込み失敗は少なめに

backoff_factor

再試行の間隔を倍々に伸ばす係数

1.0。0だと間を空けずに連打する。毎日1回の収集なら数秒〜十数秒待っても困らない

status_forcelist

再試行の対象にするステータスコード

429, 500, 502, 503, 504。一時的な混雑・障害を示すものだけ。404や403は入れない

allowed_methods

再試行してよいメソッド

GET, HEAD。取得専用の処理なので、二重送信が困るPOSTは入れない

respect_retry_after_header

Retry-After の待ち時間に従うか

True。相手が「あと何秒待て」と言うなら従う

raise_on_status

上限まで再試行しても失敗したとき、例外にするか応答を返すか

False。最後の応答を受け取り、429なら当日の収集を打ち切る判断を自分のコードで行う

backoff_factor の待ち時間は「倍々」で伸びる

backoff_factor を設定すると、再試行のたびに待ち時間がおおむね倍になります(係数1.0なら1秒→2秒→4秒…のイメージ)。待ち時間の上限には backoff_max という引数があります。ただ、何回目から待つか・上限の既定値はいくつかといった細部は、urllib3の版によって違います。正確な秒数が必要なら、手元の版で help(Retry) と公式ドキュメントを確認してください。この記事では具体的な秒数を前提にしない作りにしています。

respect_retry_after_header は429・503のときに効く

Retry-After を読むのは、429や503のように「待てば回復する」ことを示す応答のときです。注意点は、相手が長い待ち時間を返せば、スクリプトはその間ずっと眠ることです。毎日1回のジョブならそれで問題ありません。むしろ相手の指示どおりです。ただし、後述のsystemd timerでは実行時間の上限(TimeoutStartSec)を必ず決めておき、ジョブが終わらないまま翌日の分と重ならないようにします。

timeout は Retry とは別に毎回渡す

Retry は「失敗したらどうするか」の設定で、「いつ失敗とみなすか」は決めません。requestsは timeout を渡さないと応答を無期限に待ちます。cronで回すスクリプトが「エラーも出さずに終わらない」原因の多くはこれです。session.get(url, timeout=(5, 30)) のように、接続5秒・読み込み30秒といった上限を毎回渡します。

完成版コード:requests 2.32 + urllib3 2.x で429・503を待って再試行する収集スクリプト

ここまでの設定を、毎日動かす前提のスクリプトにまとめます。取得先は example.com の一覧ページ、保存先は /home/username/scraper/data とします。

import logging
import sys
import time
from pathlib import Path
from urllib.robotparser import RobotFileParser

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://example.com"
URLS = [f"{BASE}/list?page={n}" for n in range(1, 6)]
OUT = Path("/home/username/scraper/data")
USER_AGENT = "example-collector/1.0 (+https://example.com/about-bot)"
DEFAULT_INTERVAL = 3.0


def make_session() -> requests.Session:
    retry = Retry(
        total=5,
        connect=3,
        read=2,
        status=4,
        backoff_factor=1.0,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset({"GET", "HEAD"}),
        respect_retry_after_header=True,
        raise_on_status=False,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    session.headers.update({"User-Agent": USER_AGENT})
    return session


def load_robots(session: requests.Session) -> RobotFileParser:
    rp = RobotFileParser()
    resp = session.get(f"{BASE}/robots.txt", timeout=(5, 30))
    rp.parse(resp.text.splitlines() if resp.status_code == 200 else [])
    return rp


def save(url: str, html: str) -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    page = url.rsplit("=", 1)[-1]
    (OUT / f"page_{page}.html").write_text(html, encoding="utf-8")


def main() -> int:
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(name)s %(message)s")
    session = make_session()
    rp = load_robots(session)
    interval = max(DEFAULT_INTERVAL, rp.crawl_delay(USER_AGENT) or 0)

    failed = 0
    for url in URLS:
        if not rp.can_fetch(USER_AGENT, url):
            logging.info("robots.txt で許可されていないので飛ばす: %s", url)
            continue
        try:
            resp = session.get(url, timeout=(5, 30))
        except requests.exceptions.RequestException as e:
            logging.error("取得失敗 %s: %s", url, e)
            failed += 1
            continue

        if resp.status_code == 429:
            logging.warning("再試行しても429のため、本日の収集を打ち切る")
            return 1
        if resp.status_code != 200:
            logging.warning("status=%s %s", resp.status_code, url)
            failed += 1
            continue

        save(url, resp.text)
        time.sleep(interval)

    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

make_session():再試行の方針をセッションに一度だけ持たせる

Retry を HTTPAdapter に渡し、それを session.mount() で https:// と http:// に取り付けています。以後このセッションで送る get() にはすべて同じ再試行方針が掛かるので、呼び出し側に for ループで自前のリトライを書く必要はありません。自前のリトライと Retry を二重に掛けると、回数が掛け算で増えて相手への連打になるので、どちらか片方にします。User-Agent には何のための収集かが分かる名前と、説明ページのURL(ここでは example.com)を入れておくと、相手が問い合わせ先を判断できます。

load_robots():robots.txt も同じセッションで取る

標準ライブラリの RobotFileParser は自分でも取得できます。ただ、ここではタイムアウトと再試行が効いたセッションで取得し、中身だけを parse() に渡しています。robots.txt が無い(404など)場合は空のルールとして扱います。crawl_delay() はサイトがアクセス間隔を指定していればその秒数を返すので、既定の3秒と比べて長い方を採用します。

save():取れたページはその場で書き出す

最後にまとめて保存する形だと、途中で落ちたときにそれまでの取得分が全部消えます。スクレイピングは途中で止まる前提で、1ページ取るごとにファイルへ書き込むのが今の定石です。止まっても、翌日の再実行で欠けた分を確認できます。

main():429が残ったら、その日はそれ以上取りに行かない

raise_on_status=False にしたので、再試行を使い切った後の最後の応答がそのまま返ってきます。そこが429なら、相手は「まだ多い」と言い続けているということです。次のURLへ進むと同じ相手へのアクセスが続くので、その日の収集を打ち切って異常終了にします。それ以外の失敗は記録して次へ進みます。最後に、1件でも失敗があれば終了コード1を返します。終了コードを返しておくと、systemdやcronの側で「失敗した日」として残ります。

再試行が実際に起きたかはログで分かる

logging.basicConfig() でロガー名(%(name)s)を出しているので、urllib3が再試行したときの記録が urllib3.connectionpool の名前でログに出ます。「最近やたら再試行している」と分かれば、アクセス間隔を広げる・取得ページを減らすといった判断の材料になります。

相手のサーバーに負荷をかけない:robots.txt・利用規約・アクセス間隔

再試行の設定は、使い方を誤ると「落ちているサーバーを叩き続ける仕組み」になります。毎日自動で動くものほど、次の点を最初に決めておきます。

  • robots.txt を読む:上のコードのように can_fetch() で許可を確かめ、Crawl-delay の指定があれば従います。
  • 利用規約を読む:robots.txt で許可されていても、利用規約で自動取得を禁じているサイトはあります。規約違反は法的リスクやアクセス拒否につながります。個人情報を含むページでは、GDPRなど各地域のプライバシー法規も関わってきます。
  • 必要な分だけ取る:一覧で足りるなら詳細ページまで辿らない、変化しない過去分は取り直さない、という最小限の収集にします。
  • 間隔を空け、並列にしない:1ページごとに数秒空け、同じサイトに同時接続しません。backoff_factor を0にしない、status_forcelist に404・403を入れない、も同じ理由です。
  • 429・403が続いたら止める:待っても回復しないなら、それは相手の意思です。IPを変える・ブラウザを偽装するといった制限を回避する方向には進みません。公式APIの有無を確かめるか、取得をやめます。

Bot対策の強化で、ブロックされるスクレイパーは増えています。ただ、それを「回避すべき障害」とは捉えないでください。相手が受け入れられる頻度を超えている合図と受け取るほうが、結果的に長く動き続けます。

毎日動かし続けるには:手元のPC・共有サーバーのcron・VPSのsystemd timer

再試行をきちんと書いても、スクリプトを起動する側が止まれば意味がありません。requests + BeautifulSoup 構成のメモリ使用量は約50MBと軽く、Playwright(1タブ約200MB)やSelenium(1タブ約500MB)に比べて、置き場所の選択肢が広いのが利点です。

置き場所

向いている条件

止まる条件

手元のPC(Macならlaunchd)

まず試す段階・取得が少量

電源断・ログアウト。スリープ中はcronだと発火せず、その日の分が飛ぶ

共有レンタルサーバーのcron

今回のような requests だけの軽い収集

実行時間・プロセス数などの制限に当たったとき。ブラウザを動かす処理は置けないことが多い(契約先の仕様で確認)

VPS・自宅サーバーのsystemd timer

毎日確実に動かしたい・ログを残したい・ブラウザも使う

OSの更新や監視を自分でやらなかったとき。電源断(停止中の分は Persistent=true で起動時に補完)

手元のMac:cronではなくlaunchdの StartCalendarInterval

Mac自身がcronではなくlaunchdを推奨しています。launchdの StartCalendarInterval は、スリープ中に予定時刻を過ぎても、復帰後にジョブを実行します。cronにはない動きです。時刻を指定するときは、Minute キーを省略すると毎分実行になりうるので、Hour と Minute を必ず両方書きます。ログは StandardOutPath・StandardErrorPath で出力先を指定します。ただし、電源を切っていれば当然動きません。

共有レンタルサーバー:cronの1行で足りる

30 6 * * * cd /home/username/scraper && .venv/bin/python fetch.py >> /home/username/scraper/fetch.log 2>&1

仮想環境のPythonを絶対パスで呼び、出力をログファイルへ追記します。cronはログを自分で残さないと、失敗した日の手がかりが何も残りません。サーバーの時刻が日本時間とは限らないので、指定した時刻に実際に動くかを最初の数日は確かめてください(utcnow非推奨とcronの日本時間指定|26年10月で詳しく扱っています)。なお、Amazon Linux 2023ではcronの実体である cronie が既定で入っておらず、Red Hat Enterprise Linux 8でもsystemd timerへの移行が進んでいます。VPSを新しく借りるなら、次のsystemd timerが第一候補です。

VPS:systemd timer で Persistent と実行時間の上限を持たせる

ユーザー単位のユニットとして、~/.config/systemd/user/ に2つのファイルを置きます。

# scraper.service
[Unit]
Description=example.com daily collector

[Service]
Type=oneshot
WorkingDirectory=/home/username/scraper
ExecStart=/home/username/scraper/.venv/bin/python fetch.py
TimeoutStartSec=30min
# scraper.timer
[Unit]
Description=Run example.com collector daily

[Timer]
OnCalendar=*-*-* 06:30:00
RandomizedDelaySec=10min
Persistent=true

[Install]
WantedBy=timers.target
  • TimeoutStartSec=30min:Retry-After で長く待たされたり、どこかで固まったりしても、30分で打ち切ります。翌日の実行と重なりません。
  • RandomizedDelaySec=10min:起動時刻を最大10分ずらします。キリのいい時刻に収集が集中するのを避けられ、相手への配慮になります。
  • Persistent=true:サーバーが止まっていた間に逃した実行を、起動時に1回補います。

登録と確認は次のとおりです。ユニットファイルを書き換えたら、毎回 daemon-reload が要ります。

systemctl --user daemon-reload
systemctl --user enable --now scraper.timer
systemctl --user list-timers
journalctl --user -u scraper.service --since today
loginctl enable-linger username   # ログアウト後もユーザーのタイマーを動かす

上のスクリプトが返す終了コード1は、systemctl --user status scraper.service で失敗として表示されます。出力はすべて journalctl に入るので、urllib3の再試行ログもそこで確認できます。依存パッケージを固定したまま定期実行したい場合は、uv run --scriptでcron定期実行|26年10月の方法も使えます。

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

まとめ:method_whitelist は allowed_methods に、429は「待って1回、駄目なら止める」

urllib3 2系では Retry(method_whitelist=...) が削除され、allowed_methods に置き換わりました。書き換えるときは名前を変えるだけで済ませないでください。status_forcelist に429を入れ、backoff_factor で間隔を倍々に伸ばし、respect_retry_after_header=True で相手の指定した待ち時間に従います。さらに raise_on_status=False で最後の応答を受け取り、429が残ったらその日の収集を打ち切ります。これで「一時的な混雑では止まらず、相手が嫌がっているときは引く」スクリプトになります。timeout は毎回渡し、取れたページはその場で保存し、失敗は終了コードで返します。置き場所は、手元ならlaunchd、軽い収集なら共有サーバーのcron、確実に回すならsystemd timerの Persistent=true と TimeoutStartSec です。まずは pip show urllib3 で手元の版を確かめるところから始めてください。