Pythonのもう一つのHTTPクライアント: Niquests

過去数回の記事で Requests や httpx の問題点や細かい挙動について触れてきました。これらのライブラリの代わりになるもう一つの有力なHTTPクライアントとして Niquests を紹介します。

NiquestsはRequestsのforkで、高い互換性を保ちながら、非同期処理やHTTP/2、HTTP/3のサポートなど、現代的な機能を追加しています。Requestsの低レイヤーを担う urllib3 もforkして urllib3-future として提供しており、Niquestsの現代的な機能は urllib3-future によって実装されています。

Niquestsもurllib3-futureも開発されているのは Ahmed Tahri (@Ousret) さんです。個人プロジェクトとなると不安もありますが、彼は Requests の依存ライブラリの一つである charset_normalizer のメンテナでもあるため、 Requests から niquests へ移行してもサプライチェーン・アタックに関するリスクはほとんど増えないと思います。

urllib3-futureのシャドーイング

Niquestsは普通のライブラリでRequestsと共存、併用できるのですが、urllib3-futureはurllib3を完全に置き換える形になっています。 urllib3-futureさえインストールすればNiquestsだけでなくRequestsもurllib3-futureを使うようになるため、自動的にHTTP/2やHTTP/3に対応します。

このシャドーイングの動作を説明しておきます。urllib3-futureは urllib3_future というパッケージとしてインストールされるのですが、 urllib3_future.pth というファイルを使ってPythonの起動時に urllib3_future/urlllib3/ に上書きします。

この上書き動作は初回起動時に実行されるので、たとえばコンテナをビルドする場合などでPythonを利用するより前にPythonの仮想環境構築を終わらせてしまいたい場合に注意が必要です。例えば uv sync の場合は環境変数 UV_COMPILE_BYTECODE=1 を設定しておけばバイトコードコンパイルのタイミングで上書きが発生するはずですが、一度 python -c 'import urllib3; print(urllib3.__version__)' を実行してログを確認しておくと良いでしょう。パッチバージョン部分が900以上であればurllib3-futureに置き換わっています。

pthファイルを使った上書きコピーはとてもお行儀が悪い動作になるのですが、そこまでして urllib4 等の別パッケージ名ではなく urllib3 というパッケージにこだわるのには理由があります。NiquestsはRequestsのエコシステムをなるべく壊さずにPythonのHTTPクライアントライブラリを進化させることを目的としているのですが、Requestsを拡張するライブラリの多くが urllib3 に依存しているのです。

どうしてもRequestsはそのままで使いたい場合は、 ドキュメントのcohabitation節にソースパッケージからpthファイル抜きでインストールする方法が書かれています。

urllib3-futureのHTTP実装について

urllib3のHTTP/1.1サポートは標準ライブラリの http.client をベースにしていますが、 urllib3-future はhttpxが使ってるhttpcoreと同じく h11を使っています。h11は http.client と同じくPure Python実装ですが、実用的な速度は出ています。

HTTP/2のサポートにはhttpcoreが使っているh2 (hyper-h2)をそのまま使うのではなく、細分化されすぎたh2の依存ライブラリをまとめて、さらに性能が必要な部分はRustを使った高速化モジュールも用意したjh2を使っているので、httpcoreよりもHTTP/2の性能が良い可能性があります。

また、aioquicをforkして開発している qh3 を使ってHTTP/3までサポートしています。urllib3やhttpxによるHTTP/2サポートがまだデフォルトで有効になっていないしHTTP/3には対応していないのに対して、urllib3-futureはデフォルトでHTTP/2とHTTP/3が有効になっているのが特徴です。

コネクションプールとkeep-aliveの設定

過去数回の記事でRequestsやhttpxのコネクションプールやkeep-aliveについて触れてきましたが、Niquestsについても調べてみました。

まず、Requestsに対して大きな進歩になるのが niquests.Session() が引数でコネクションプールの設定ができる点です。 requestsで長時間Sessionを使う場合はidle_timeoutに注意 では同一ホスト宛の接続を1つだけに制限することで長時間idleになるコネクションが生まれにくくする方法を紹介しましたが、その時はこのようなコードを書く必要がありました。

import requests
from requests.adapters import HTTPAdapter

session = requests.Session()
session.mount("http://", HTTPAdapter(pool_maxsize=1))
session.mount("https://", HTTPAdapter(pool_maxsize=1))

Niquestsでは HTTPAdapter の引数をSessionに渡せるので、同じことが簡単に書けます。

import niquests

session = niquests.Session(pool_maxsize=1)

設定できる引数からいくつか紹介します。

  • pool_connections は名前だけみると接続数に見えますが、実際にはコネクションプールを保持するホスト数の上限になります。実際のコネクションプールはホストごとに分けて作られています。デフォルトでは10です。
  • pool_maxsize は1ホストあたり(コネクションプールあたり)のコネクション数の上限です。これもデフォルトでは10です。
  • pool_block はコネクション数がいっぱいになったときに、既存の接続が空くのを待つかどうかです。デフォルトはFalseで、 pool_maxsize を超えて接続を作成します。(Falseであれば、 pool_maxsize のデフォルトの10は少し大きすぎる気がします。)
  • keepalive_delay は、docstringにはHTTP/2でidle接続に対してPINGを送り始めるまでの秒数と書かれているのですが、HTTP/1.1では接続の寿命として振る舞います。デフォルトは3600秒(1時間)です。

上の3つはRequestsのHTTPAdapterと共通ですが、最後の keepalive_delay についてはNiquestsとurllib3-futureの独自拡張になります。 以前の記事で urllib3にidle_timeoutを追加するPRを紹介しましたが、それはidle状態が指定秒数続いたら再利用せずに捨てるものでした。接続開始からの経過秒数で廃棄する keepalive_delay は少し動作が異なりますが、問題だったサーバー側の idle_timeout によりリクエスト送信と同時に接続がサーバーから切断されるとリクエストが処理されたかどうだか分からなくなるという問題を避けるのに利用できるのは同じです。

例えば nginxの設定だと、クライアントとの接続は開始から最大1時間維持され(keepalive_time)、75秒idleだと切断されます(keepalive_timeout)。これに合わせるのであれば、今はidle_timeoutを避けるために keepalive_delay=60 くらいに設定しておき、将来 urllib3-future に idle_timeout が実装されたら idle_timeout=60 に、 keepalive_delay は1時間より少し短い値 (3500くらい)に設定するのが良いでしょう。

なお、この話題について語ったときに httpx の keepalive_expiryidle_timeout と同じものとして紹介したのですが、HTTP/1.1ではうまく行くもののHTTP/2を有効にするとサーバー側から接続が切られる現象が報告されています。 (Issue) HTTP/2を使いたいのであればhttpxよりもNiquestsの方がエラーが少ないかもしれません。

httpxのパフォーマンス問題について

前回の記事でhttpxの検討を進めた後にこんな気になる記事を見かけたので現状を調査しました。

gfx.hatenablog.com

結論から言うと、これは httpx を async で利用する時の問題で、現状ではまだ解決されていません。同期APIを使っていれば問題ありません。

原因は、httpxの低レイヤーライブラリであるhttpcoreがanyio.Lockを使っていたのですが、その実装がasyncio.Lockよりも大幅に遅いことです。httpcoreに提案されている解決策は3つあります。

  1. anyio依存排除 https://github.com/encode/httpcore/pull/922
  2. anyioに追加された fast_acquire を利用する https://github.com/encode/httpcore/pull/953
  3. コネクションプールの実装見直し https://github.com/encode/httpcore/pull/927

残念ながら a も b も、cの方が良いからという理由でマージされていません。 b は一度マージされたのですが revert されてしまいました。 https://github.com/encode/httpcore/pull/1002

cも開発が止まっているように見えますが、開発者の Kim Christie さんは2週間前から httpx のほとんどリライトとなる v1 ブランチをスタートしています。 https://github.com/encode/httpx/commits/v1/

ということで、開発が止まっているわけではないものの、asyncio利用時のパフォーマンス問題がいつ頃解決されるかは全く目処が立ちません。asyncioを使っていてパフォーマンスを無視できないのであれば aiohttp を使う他なさそうです。 httpxの機能が必要でaiohttpへの移行が大変な場合は、httpxの下位レイヤーをaiohttpにするためのコードがコメント欄で書かれていたので参考にしてください。 https://github.com/encode/httpx/issues/3215#issuecomment-2522013017

追記

httpxのパフォーマンス問題について - methaneのブログ

“結論から言うと、これは httpx を async で利用する時の問題で、現状ではまだ解決されていません。同期APIを使っていれば問題ありません” / コネクションプーリングしない、でもいいと思います。

2025/09/29 12:29
b.hatena.ne.jp

そうですね。その場合はSSLContextの生成に時間がかかるのですが、次のようにSSLContextを使い回す方法があります。

methane.hatenablog.jp

requestsで長時間Sessionを使う場合はidle_timeoutに注意

Pythonで一番人気のあるHTTPクライアントライブラリはrequestsですが、requestsやその低レイヤーであるurllib3はidle_timeoutの設定を持っていないので、長時間アイドルが続いた接続を再利用した時に Connection Reset by Peer エラーが発生することがあります。

このエラーを避けるためにurllib3はリクエストを送信する前に0バイトのreadを行って接続が生きているか確認しているのですが、サーバー側が接続を切断するのと同時にリクエストを送信してしまう場合にはその確認をすり抜けるので、ごく低頻度にエラーが起こってしまいます。

意図的にこのエラーを再現させてみます。Goを使ってidle_timeoutが1秒のサーバーを作ります。

package main

import (
    "net/http"
    "time"
    "fmt"
    "log"
)

func myHandler(w http.ResponseWriter, r *http.Request) {
    time.Sleep(100 * time.Millisecond)
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    fmt.Fprintf(w, "Hello, world!")
}

func main() {
    s := &http.Server{
        Addr:           ":8080",
        Handler:        http.HandlerFunc(myHandler),
        IdleTimeout:    1 * time.Second,
    }
    log.Fatal(s.ListenAndServe())
}

このサーバーに対して1秒弱の間隔でリクエストを送信します。

import requests
import threading
import time
import random

url = "http://127.0.0.1:8080"  # app.go

def get():
    session = requests.Session()
    last_sleep = 0.0
    for i in range(100):
        try:
            response = session.get(url)
            response.raise_for_status()
        except requests.exceptions.ConnectionError as e:
            print(e)
            print(f"{last_sleep=}sec")
        # keep-alive timeout が 1s のサーバーに対してギリギリのタイミングでリクエストを投げる
        last_sleep = random.uniform(0.99, 1.0)
        time.sleep(last_sleep)

workers = []

for i in range(10):
    th = threading.Thread(target=get)
    th.start()
    workers.append(th)

for worker in workers:
    worker.join()

実行結果:

('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9935746861945842sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9940835216122245sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9905876213085897sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9944683230422835sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9906204586986777sec
('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
last_sleep=0.9910259433567449sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9903877980940736sec
...

たとえば外部APIを利用するWebアプリケーションで、外部APIの呼び出しが低頻度だとかマルチスレッドを使っていると問題が発生しやすいです。低頻度の場合はSessionを使わないのが一番簡単な解決策ですが、アクセスが高頻度でもマルチスレッドを利用している場合は稀な頻度で起こる同時接続のためにkeep_aliveされる接続が増えて、一部の接続が長時間アイドルになることがあるので、同時接続数を減らすのが良いでしょう。

urllib3 では PoolManager の maxsize で同時接続数を制限できてデフォルトで1なのですが、 requests ではこれを10に置き換えてしまっており、これはほとんどのアプリケーションにとっては過剰でしょう。最大接続数を超えてもデフォルトの設定ではブロックせずに新規接続してくれるので、基本的には maxsize=1 を使い、それで足りないような場合にだけ増やすのがいいと思います。 requestsでSessionを作成する時にmaxsizeを指定できないので、カスタマイズするためにはこのようにします。

import requests
from requests.adapters import HTTPAdapter

session = requests.Session()
session.mount("http://", HTTPAdapter(pool_maxsize=1))
session.mount("https://", HTTPAdapter(pool_maxsize=1))

urllib3にidle_timeoutを設定できるようにするPRがあるので、これがマージされればもっと良い解決ができるようになるでしょう。

または、 httpx への置き換えも検討してみてください。 httpx は高水準APIはrequestsとよく似ており、ほとんど Session を Client に置き換えるだけで使えます。 httpx.Limits.keepalive_expiry で idle_timeout を指定可能で、デフォルトでは5秒になっています。先ほどの再現コードで session 変数を作る部分を次のように書き換えるだけでエラーなしに動作するようになります。

    # import requests の代わりに import httpx
    session = httpx.Client(limits=httpx.Limits(keepalive_expiry=0.5))

functools.cacheをメソッドに使う

functools.cache は便利ですが、メソッドに対して使う時には注意が必要です。

from functools import cache

class A:
    @cache
    def f(self, x):
        return x * 2

for i in range(1000):
    a = A()
    a.f(42)

print(A.f.cache_info())
# CacheInfo(hits=0, misses=1000, maxsize=None, currsize=1000)

このコードでは A.f() メソッドの第一引数 self がキャッシュキーに含まれるためキャッシュが効いていません。単に効かないどころか Aインスタンスが無限にキャッシュに残り続けるのでメモリリークになります。

この問題を回避するには f() を staticmethod にするか、Aの外で通常の関数として定義する必要があります。

from functools import cache

class A:
    # デコレーターの順番に注意
    @staticmethod
    @cache
    def f(x):
        return x * 2

for i in range(1000):
    a = A()
    a.f(42)

print(A.f.cache_info())
# CacheInfo(hits=999, misses=1, maxsize=None, currsize=1)

これでちゃんとキャッシュヒットするようになり、メモリリークも防いでいます。 @staticmethod@cache の順番は逆になってはいけません。 cache が返すラッパー関数が staticmethod でないので、 A.f により self がバインドされてしまいます。 (staticmethod, cache, staticmethod の順番で f 本体とラッパー関数両方を staticmethod にしても良いですが、冗長です。)

一方でインスタンス間でキャッシュをシェアしたくない場合は一工夫必要になります。デコレーターとして使ってしまうとcacheがクラスに所属してしまうので、インスタンスと生存期間が同じになるようにcacheを __init__ で生成してあげます。

from functools import cache

class A:
    def __init__(self, x):
        self._x = x
        self.f = cache(self._f)

    def _f(self, y):
        return self._x * y

for i in range(10):
    a = A(i)
    for j in range(10):
        a.f(42)
    print(i, a.f.cache_info())
# 0 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 1 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 2 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 3 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 4 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 5 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 6 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 7 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 8 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
# 9 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)

インスタンスごとにキャッシュが分かれて、それぞれが9回ヒットしていることが判ります。

コネクションプールなしでhttpxを使う場合の高速化

httpx をコネクションプールありで使う場合は client = httpx.Client() して client.get() などを使いますが、コネクションプールなしで使う場合は httpx.get() などを使います。

この httpx.get() のような単発でHTTPリクエストを実行するAPIは実際には内部で httpx.Client()インスタンスを生成して破棄しています。実はこの Client()インスタンス生成が遅いのです。

# x1.py
from httpx import Client
for _ in range(100):
    c = Client()
$ hyperfine '.venv/bin/python x1.py'
Benchmark 1: .venv/bin/python x1.py
  Time (mean ± σ):      1.484 s ±  0.018 s    [User: 1.421 s, System: 0.041 s]
  Range (min … max):    1.455 s …  1.522 s    10 runs

100回クライアントを作るのに1.5秒かかっています。1回あたり15msです。もうちょっとなんとかしたい。

実はこのクライアントの生成時間のほとんどは、SSLContext()を作るのに使われています。コネクションプールを使わない場合もSSLContextだけを使い回すことでhttpxを高速化できます。

from httpx import Client, create_ssl_context
ssl_context = create_ssl_context()
for _ in range(100):
    c = Client(verify=ssl_context)
$ hyperfine '.venv/bin/python x1.py' '.venv/bin/python x2.py'
Benchmark 1: .venv/bin/python x1.py
  Time (mean ± σ):      1.477 s ±  0.024 s    [User: 1.412 s, System: 0.040 s]
  Range (min … max):    1.453 s …  1.532 s    10 runs

Benchmark 2: .venv/bin/python x2.py
  Time (mean ± σ):     120.7 ms ±   5.3 ms    [User: 85.6 ms, System: 15.2 ms]
  Range (min … max):   104.2 ms … 129.6 ms    22 runs

Summary
  .venv/bin/python x2.py ran
   12.23 ± 0.58 times faster than .venv/bin/python x1.py

10倍以上高速化できました。

実際に使う場合には get() などのメソッドが verify キーワード引数を受け取るのでそこに SSLContext を渡します。

import httpx

ssl_context = httpx.create_ssl_context()
res = httpx.get('https://google.com', verify=ssl_context)
print("Status:", res.status_code)
print(res.text[:100])
このブログに乗せているコードは引用を除き CC0 1.0 で提供します。