0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python標準ライブラリだけでAPIクライアントを書く:urllibのPOST・タイムアウト・リトライ

0
Posted at

はじめに

小さなツールに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

ハマった点

  1. except の順序に注意。HTTPError は URLError のサブクラスなので、URLError を先に書くと404や429がすべて「接続エラー」側に吸われてしまう。必ず HTTPError を先に。
  2. HTTPError は「例外」であり「レスポンス」でもある。e.read() でエラーボディが読める。APIが {"error": "..."} を返す設計なら、これを読んでからリトライ可否を判断する。e.headers から Retry-After も取れる。
  3. タイムアウトは TimeoutError で届いた(Python 3.11)。socket.timeout は TimeoutError の別名で、URLError に入っている場合もあるため、両方をまとめて捕まえる書き方が安全。
  4. POSTは data= を渡せば自動でPOSTになる。method= を書かなくても動く(実測)。ただし Content-Type と User-Agent は自動では付かないので自分で設定する。
  5. Retry-After を無視して即リトライすると行儀が悪い。実測でも 503 の2回分で約2秒待って成功している。429 は「上限で諦めた」結果を (429, body) として返し、呼び出し側が判断できる形にした。

まとめ

  • urllib だけで「POST・タイムアウト・リトライ・エラーボディ読取」まで含む実用的なクライアントが書ける。
  • 例外は HTTPError(レスポンス兼用)→ URLError / TimeoutError の順で処理する。
  • リトライ対象は 429・5xx に限定し、Retry-After があれば従う。
  • ローカルの http.server でエンドポイントを再現すると、挙動を全部テストできる。

関連する本

リトライ設計を含む運用の全体を一冊にまとめました。第1章・第2章は無料で読めます。

Windowsで動かすローカルLLM自動化:Ollama実測ガイド

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?