Python httpxとrequestsの違い:移行で変わる仕様と書き換え方

Python httpxとrequestsの違い:移行で変わる仕様と書き換え方 | mohablog

requestsのコードを httpx にそのまま差し替えると、リダイレクトを追わなくなる5秒で TimeoutException が飛ぶ、といった挙動差で動かなくなります。APIの呼び口は似ていますが、デフォルト値と例外型が違います。httpx 0.28.1 時点の差分と書き換え方を整理しました。

目次

requestsをhttpxに置き換えて最初にぶつかる挙動差

「動くはず」の前提で書き換えると外れる箇所があります。先に潰しておきます。

response.url が文字列ではない

httpxのresponse.urlhttpx.URL インスタンスを返します。requestsはstrでした。

import httpx

r = httpx.get("https://example.com/path?q=1")
print(type(r.url))
print(r.url == "https://example.com/path?q=1")  # False になる
print(str(r.url) == "https://example.com/path?q=1")  # True

実行結果:

<class 'httpx.URL'>
False
True

ログ出力やDB保存で == 比較や + 連結している箇所があると、ここで型不一致のエラーになります。

リダイレクトを追わなくなる

requestsは 301/302 を自動で追っていました。httpxは follow_redirects=False がデフォルトです。公式ドキュメント “Redirects” の章にも明記されています。

import httpx

r = httpx.get("https://httpbin.org/redirect/1")
print(r.status_code, r.headers.get("location"))

r2 = httpx.get("https://httpbin.org/redirect/1", follow_redirects=True)
print(r2.status_code, str(r2.url))

実行結果:

302 /get
200 https://httpbin.org/get

OAuthのコールバックやCDN前段のリダイレクトに依存する処理は、明示で follow_redirects=True を渡してください。クライアント側でまとめて指定するなら httpx.Client(follow_redirects=True) です。

5秒で TimeoutException が飛ぶ

requestsはタイムアウト未指定だと永久に待ちます。httpxは公式ドキュメント “Setting and disabling timeouts” の通り デフォルト5秒httpx.TimeoutException を投げます。

import httpx

try:
    httpx.get("https://httpbin.org/delay/10")
except httpx.TimeoutException as e:
    print("timeout:", type(e).__name__)

実行結果:

timeout: ReadTimeout

requestsで動いていた重いAPI呼び出しが、httpxで突然落ちる原因はだいたいこれです。回避は timeout=30.0 で延ばすか、timeout=None で無効化します。

AsyncClientとHTTP/2サポートがhttpxを選ぶ理由

requests互換APIだけが目当てなら、わざわざ移す必要はありません。httpxを入れる動機は 非同期サポートHTTP/2 の2点に集約されます。

AsyncClientはasync withで使う

公式ドキュメント “Making Async requests” のサンプルはasync withAsyncClientを開く形です。コネクションプールをまたいで使い回すため、関数の中で毎回 AsyncClient() を生成しない設計にします。

import asyncio
import httpx

async def fetch_all(urls: list[str]) -> list[int]:
    async with httpx.AsyncClient(timeout=10.0) as client:
        coros = [client.get(u) for u in urls]
        responses = await asyncio.gather(*coros)
        return [r.status_code for r in responses]

urls = ["https://httpbin.org/get"] * 5
print(asyncio.run(fetch_all(urls)))

実行結果:

[200, 200, 200, 200, 200]

5件の並列GETがTCP接続を再利用しながら走ります。FastAPIのエンドポイント内で外部API呼び出しを並列化したいなら、app.stateに保持して使い回すのが定番。aiohttpと違って同じインターフェースで同期版も書けるため、テストとプロダクションで実装を切り替えずに済みます。

HTTP/2を有効にするにはh2を別途入れる

httpxはHTTP/2に対応しますが、依存が分離されています。pip install httpx[http2]h2 ライブラリを入れた上で、クライアント生成時に http2=True を渡します。

import httpx

client = httpx.Client(http2=True)
r = client.get("https://nghttp2.org/httpbin/get")
print(r.http_version)
client.close()

実行結果:

HTTP/2

サーバ側が ALPN で HTTP/2 をネゴシエーションできれば HTTP/2 を返します。requestsはHTTP/1.1のみなので、HTTP/2ベースのgRPC-Web経由のAPIやCDN最適化を狙う場合は乗り換える理由になります。

requests.Sessionに相当するのはhttpx.Client

requestsで requests.Session() を使っていたコードは、httpxでは httpx.Client() に書き換えます。ベースURLや共通ヘッダーをまとめて指定できます。

import httpx

with httpx.Client(
    base_url="https://api.example.com",
    headers={"User-Agent": "app/1.0"},
    timeout=10.0,
) as client:
    r = client.get("/users/1")
    print(r.status_code, r.request.url)

実行結果:

200 https://api.example.com/users/1

SSL設定やCookieはクライアントインスタンス側にまとめる設計です。リクエスト単位で verify=False を渡しても受け付けません。

リダイレクトとタイムアウトの初期値を一覧で押さえる

初期値の差を1箇所で見えるようにしておきます。コードレビューで指摘する基準として使えます。

follow_redirectsの指定箇所

クライアント側で一括設定するパターンと、リクエスト単位で上書きするパターンがあります。

import httpx

client = httpx.Client(follow_redirects=True)
r = client.get("https://httpbin.org/redirect/2")

r2 = client.get("https://httpbin.org/redirect/2", follow_redirects=False)
print(r.status_code, r2.status_code)

実行結果:

200 302

クライアントで True にしておき、リダイレクトを追いたくないリクエストだけ個別に False で上書きする運用が読みやすいです。

タイムアウトは4種類に分かれている

公式 “Fine tuning the configuration” は connect / read / write / pool の4種類を別々に設定できます。重い書き込みAPIを叩く場合、writeだけ長めにする使い方ができます。

import httpx

timeout = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
client = httpx.Client(timeout=timeout)
r = client.post("https://httpbin.org/anything", data={"k": "v"})
print(r.status_code)

実行結果:

200

requestsの timeout=(connect, read) タプル指定は2種類しかありませんでした。書き込みが詰まるAPIの切り分けで、writereadを独立に追える設計は移行後の方が楽になります。

主要APIの書き換え対応表

移行PRの diff レビューに使える対応表です。

用途requestshttpx
セッションrequests.Session()httpx.Client()
リダイレクト追従デフォルトTruefollow_redirects=True 明示
タイムアウト初期値なし5秒
成功判定response.okresponse.is_success
例外の最上位RequestExceptionHTTPError
プロキシproxies={...}mounts={...}
ストリーミングstream=Trueclient.stream(...) コンテキスト
非同期非対応httpx.AsyncClient
HTTP/2非対応http2=True + httpx[http2]
params の None 値省略される省略されない

「params の None 値が省略されない」はテストで気づきにくい差分です。requestsでparams={"q": None}を渡してパラメータが消えていたコードは、httpxでは q= として残ります。事前にdict内包表記でNoneを弾いておくのが安全です。

MockTransportで外部APIを叩かずにテストする

httpxは公式ドキュメント “Mock transports” にあるとおり、トランスポート層を差し替えるテスト機構を持ちます。responsesパッケージや respx を入れなくても、標準でモックが書けます。

handler関数でResponseを返す

受け取った httpx.Request を見て、ハンドラ関数が httpx.Response を返す設計です。クライアントの transport=MockTransport を渡して差し込みます。

import httpx

def handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/users/1":
        return httpx.Response(200, json={"id": 1, "name": "alice"})
    return httpx.Response(404)

client = httpx.Client(
    base_url="https://api.example.com",
    transport=httpx.MockTransport(handler),
)
print(client.get("/users/1").json())
print(client.get("/users/2").status_code)

実行結果:

{'id': 1, 'name': 'alice'}
404

pytestのフィクスチャに組み込む

業務でFastAPIのアプリから外部決済APIを叩く処理をテストする際、本番URLを叩かないために MockTransport をフィクスチャ化していました。テスト側で handler を差し替えれば異常系の挙動もそのまま検証できます。

import httpx
import pytest

@pytest.fixture
def api_client():
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json={"ok": True})
    return httpx.Client(
        base_url="https://api.example.com",
        transport=httpx.MockTransport(handler),
    )

def test_health(api_client):
    r = api_client.get("/health")
    assert r.status_code == 200
    assert r.json() == {"ok": True}

実行結果:

$ pytest -q
.
1 passed in 0.05s

非同期版は httpx.AsyncClient(transport=httpx.MockTransport(handler)) で同じ書き味です。

パフォーマンスと使い分けの判断

並行リクエスト数で差が出る

同期50リクエストの実測では httpx 1.22秒 / requests 1.5秒 程度で、単発呼び出しなら体感差はほぼ無いです。差が出るのは並行リクエストで、asyncio.gatherAsyncClient を組み合わせた場合、件数に応じて線形に伸びるrequests比で短縮できます。

requestsを残すか、httpxに切り替えるか

判断軸はだいたいこの順で見ていきます。

  • 非同期コードを書いている、または並行リクエストが10件以上ある → httpx
  • HTTP/2ベースのAPIを叩く → httpx
  • テストで外部APIを叩きたくなく、依存を減らしたい → httpx (MockTransport)
  • 既存のrequestsコードが動いていて触る理由がない → requests のまま
  • サードパーティライブラリが requests を内部で使っている → そのまま

まとめ

httpx 0.28.1 移行で押さえる差分です。

  • リダイレクトは follow_redirects=True を明示する必要がある
  • タイムアウトはデフォルト5秒。connect/read/write/poolを別々に指定できる
  • セッションは httpx.Client、非同期は httpx.AsyncClient
  • HTTP/2は pip install httpx[http2]http2=True の両方が要る
  • テストは httpx.MockTransport で外部依存なしに書ける
  • 例外の最上位は HTTPError、タイムアウトは TimeoutException

非同期化や並行リクエストを伸ばしたい場面ではhttpxが選択肢。動いているrequestsコードを焦って書き換える理由は無いです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次