はじめに
小さなツールにAPI連携を仕込むとき、依存を増やしたくないので urllib だけで書きたいことがある。だが実際に書いてみると、404のエラーボディの読み方、タイムアウトの例外の種類、リトライの条件で毎回つまずく。ローカルにテスト用HTTPサーバーを立てて、挙動をすべて実測しながら「そのまま使える最小のクライアント」を作った。
環境
- Windows 11(ビルド 10.0.26200)
- Python 3.11(
urllib/http.serverともに標準ライブラリ)
やったこと
1. テスト用のローカルHTTPサーバー
検証相手は標準ライブラリの http.server。意図的に以下のエンドポイントを作る。
-
/ok… 200 -
/slow… 4秒待ってから200(タイムアウト用) -
/flaky… 最初の2回は 503 +Retry-After: 1、3回目に200 -
/limited… 常に 429 +Retry-After: 2 - それ以外 … 404 + JSONボディ
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/flaky":
state["flaky"] += 1
if state["flaky"] < 3:
self._send(503, {"error": "busy"}, {"Retry-After": "1"})
else:
self._send(200, {"msg": f"ok after {state['flaky']} attempts"})
# ...(省略)
2. クライアント本体(これが結論のコード)
import json, time, urllib.error, urllib.request
def request_json(url, data=None, timeout=5, retries=3, backoff=1.0):
payload = json.dumps(data).encode("utf-8") if data is not None else None
headers = {"User-Agent": "mycli/1.0"}
if payload is not None:
headers["Content-Type"] = "application/json" # 自分で付ける
for attempt in range(1, retries + 1):
req = urllib.request.Request(url, data=payload, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
return res.status, json.loads(res.read().decode("utf-8"))
except urllib.error.HTTPError as e: # URLErrorより先に!
body = json.loads(e.read().decode("utf-8"))
retry_after = e.headers.get("Retry-After")
if e.code in (429, 500, 502, 503, 504) and attempt < retries:
time.sleep(float(retry_after) if retry_after else backoff * attempt)
continue
return e.code, body
except (urllib.error.URLError, TimeoutError):
if attempt < retries:
time.sleep(backoff * attempt)
continue
raise
3. 実行結果(実測)
== 1. GET /ok ==
(200, {'msg': 'hello'})
== 2. POST JSON ==
(200, {'received': {'name': 'テスト', 'n': 2}})
== 3. 404のエラーボディ ==
(404, {'error': 'not found', 'path': '/nope'})
== 4. 503が2回->成功(リトライ+Retry-After) ==
(200, {'msg': 'ok after 3 attempts'})
経過: 2.0秒
== 5. 429はリトライ上限でボディを返す ==
(429, {'error': 'too many'})
経過: 2.0秒
== 6. タイムアウト(/slow を timeout=1 で) ==
TimeoutError timed out
ハマった点
-
exceptの順序に注意。HTTPErrorはURLErrorのサブクラスなので、URLErrorを先に書くと404や429がすべて「接続エラー」側に吸われてしまう。必ずHTTPErrorを先に。 -
HTTPErrorは「例外」であり「レスポンス」でもある。e.read()でエラーボディが読める。APIが{"error": "..."}を返す設計なら、これを読んでからリトライ可否を判断する。e.headersからRetry-Afterも取れる。 -
タイムアウトは
TimeoutErrorで届いた(Python 3.11)。socket.timeoutはTimeoutErrorの別名で、URLErrorに入っている場合もあるため、両方をまとめて捕まえる書き方が安全。 -
POSTは
data=を渡せば自動でPOSTになる。method=を書かなくても動く(実測)。ただしContent-TypeとUser-Agentは自動では付かないので自分で設定する。 -
Retry-Afterを無視して即リトライすると行儀が悪い。実測でも 503 の2回分で約2秒待って成功している。429 は「上限で諦めた」結果を(429, body)として返し、呼び出し側が判断できる形にした。
まとめ
-
urllibだけで「POST・タイムアウト・リトライ・エラーボディ読取」まで含む実用的なクライアントが書ける。 - 例外は
HTTPError(レスポンス兼用)→URLError/TimeoutErrorの順で処理する。 - リトライ対象は 429・5xx に限定し、
Retry-Afterがあれば従う。 - ローカルの
http.serverでエンドポイントを再現すると、挙動を全部テストできる。
関連する本
リトライ設計を含む運用の全体を一冊にまとめました。第1章・第2章は無料で読めます。