過去数回の記事で 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_expiry が idle_timeout と同じものとして紹介したのですが、HTTP/1.1ではうまく行くもののHTTP/2を有効にするとサーバー側から接続が切られる現象が報告されています。 (Issue)
HTTP/2を使いたいのであればhttpxよりもNiquestsの方がエラーが少ないかもしれません。