Niquestsの深刻なバグについての注意 (urllib3-future 2.15.902 で修正済み)

最近何度か紹介していた Niquests に致命的なバグがあり、問題の大きさの割にアナウンスが小さいので解説しておきます。

なお、このバグは urllib3-future 2.14.906 (2025-11-06) から発生し、2.15.902 (2026-02-03) で修正されました。 Niquests を使っている人は利用している urllib3-future のバージョンの確認を強く推奨します。

バグの内容

niquestsのSessionを使って同一ホストに対して並行にリクエストを送信したとき、その接続が HTTP/2 か HTTP/3 だと、レスポンスの取り違えが発生します。

再現コード

import niquests
import threading
import time
import sys
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("main")

session = niquests.Session(pool_maxsize=1, keepalive_delay=60)

stop = False


def get_worker(name):
    while not stop:
        response = session.get(f"https://httpbin.org/delay/1?name={name}")
        if response.status_code != 200:
            logger.error("bad status. code=%s, %s, body=%s", response.status_code, response.status, response.text)
        json = response.json()
        if json["args"]["name"] != name:
            logger.error(f"wrong name! got {json['args']['name']}, expected {name}")


def main():
    threads = []
    for i in range(4):
        t = threading.Thread(target=get_worker, args=(f"worker-{i}",))
        t.start()
        threads.append(t)

    try:
        for t in threads:
            t.join()
    except KeyboardInterrupt:
        global stop
        stop = True

        sys.stdout.write("\nStopping threads...\n")
        for t in threads:
            t.join()


if __name__ == "__main__":
    main()

実行結果

2026-03-05 18:09:24,262 ERROR wrong name! got worker-3, expected worker-2
2026-03-05 18:09:24,263 ERROR wrong name! got worker-2, expected worker-3
2026-03-05 18:09:25,716 ERROR wrong name! got worker-1, expected worker-0
2026-03-05 18:09:25,718 ERROR wrong name! got worker-0, expected worker-2
2026-03-05 18:09:25,717 ERROR wrong name! got worker-3, expected worker-1
2026-03-05 18:09:25,720 ERROR wrong name! got worker-2, expected worker-3
2026-03-05 18:09:27,687 ERROR wrong name! got worker-1, expected worker-0
2026-03-05 18:09:28,786 ERROR wrong name! got worker-0, expected worker-1

発生条件と影響

この問題は HTTP/1.1 では発生しません。1つの接続に複数のリクエストを多重化しているときにのみレスポンスの取り違えが発生します。

このバグを抱えていると、たとえば稀にあるユーザーに別のユーザーのデータを返してしまう、みたいな深刻な問題が発生する恐れがあります。

2025-11-06 に混入したバグが、 2026-02-03 に報告され、2026-02-04 に修正がリリースされました。メンテナの対応は素晴らしく早いものの、バグが混入してから見つかるまでに3ヶ月かかってしまっているのはユーザー数の少なさというデメリットが現れているのだと思います。

もともと requests で長時間 keep-alive すると稀にエラーになるのをなんとかしたいというモチベーションで niquests を使っていたので、社内で niquests を使おうとしていたあるサービスでは urllib3-future をバージョンアップするだけでなく HTTP/2 と HTTP/3 を無効にして運用することにしました。無効にするのは Session の引数で可能です。

# pool_maxsize は1ホストあたり接続をいくつまでプールするか。
# デフォルトはmaxsizeでも接続が足りなくなったら新規接続する(pool_block=False)ので、
# よっぽどのことがなければ pool_maxsize=1 で十分。
#
# keepalive_delayはHTTP/2用のオプションだが、HTTP/1.1では接続の寿命(新規接続して
# 何秒後に再利用をやめるか)として扱われる。
# 今の所idle_timeoutがないので、サーバー側のidle_timeoutにぶつかってエラーになる
# のを避けるためにkeepalive_delayを使っている。
# 60を採用したのは、nginxのデフォルトのidle_timeout 75sなのでそれより短い値として。
#
# requestsとの大きな違いとして、デフォルトでシステムのルート証明書を利用することに注意。
# ルート証明書のアップデートにはPythonのcertifiパッケージではなく Debian/Ubuntuの
# ca-certificates パッケージをアップデートすること。
#
# https://github.com/jawah/urllib3.future/issues/309
# 1つの接続に複数のリクエストを並行して処理できるHTTP2 or 3 で、並行にアクセスするとレスポンスが他の
# リクエストに行ってしまうという致命的な問題があった。 urllib3-future 2.15.902 で解決済みだが、
# 怖いのでHTTP/1.1に限定して利用する。
session = niquests.Session(pool_maxsize=1, keepalive_delay=60, disable_http2=True, disable_http3=True)

`functools.cache` や `functools.lru_cache` をメソッドに使うメモリリークはruffで検出できる

functools.cacheをメソッドに使う - methaneのブログ で紹介した、普通に functools.cache をメソッドに使うとメモリリークになってしまう問題ですが、半年ぶり2回目遭遇したので再発防止しないとなと思ったらすでに静的チェックがありました。

docs.astral.sh

次のように pyproject.toml に追加しておくと良いでしょう。

# pyproject.toml

[tool.ruff.lint]
extend-select = [
  "I",  # enable isort
  ...
  "B019",  # https://docs.astral.sh/ruff/rules/cached-instance-method/
]

メソッドのキャッシュには cached_method が便利

以前の記事で、functools.cache をそのままメソッドに使うとメモリリークになることと、その回避方法をいくつか紹介しました。

最後に紹介していた方法がこれです。

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

このコードでは A.f() が呼ばれていない時も cache(self._f) が実行されているので、 cached_property を使えばさらに改善ができます。

from functools import cache, cached_property

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

    @cached_property
    def f(self):
        return cache(self._f)

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

しかしキャッシュが必要になるたびにこのコードを書くのは面倒ですよね。それをしてくれるのが cached_method です。 uv add cached_method でインストールできます。上の例に cached_method を使うとこうなります。

from cached_method import cached_method

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

    @cached_method
    def f(self, y):
        return self._x * y

メソッドのためのcacheでコレータとしては cachetoolscachedmethod もありましたが、実装が functools.lru_cache と異なるものになるし、メソッドごとにキャッシュのdictとそれを取得するための関数を用意する必要があり面倒でした。一方で typing サポートは cachetools の方がstubがあるので痛し痒しといったところです。

uWSGIでマルチスレッド利用時のプロセス間ロードバランス

概要

私は、uWSGIを使う時は基本的にマルチプロセス、シングルスレッドの設定を推奨します。しかし、レスポンスタイムがときどき遅くなる外部API呼び出しを含む場合など、メモリ使用量やthundering herd問題を考慮しつつ多くの並列数が必要な場合にマルチスレッドとマルチプロセスを組み合わせてより高い並列数を稼ぎたい場合があります。

マルチスレッドとマルチプロセスを併用する場合、プロセス間でリクエストをうまく分散させるロードバランスが問題になります。この記事ではその問題と解決方法について実験を交えて解説します。

マルチスレッド

まずはマルチスレッドの特性を確認していきましょう。サンプルとしてこんなwebアプリケーションを用意します。実験に使っているPC上では、 fib(25) 1回あたりに約10msかかり、全体で100ms程度かかっています。

# wsgi.py
import time
import threading

def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

def app(environ, start_response):
    for _ in range(5):
        time.sleep(0.01) # 10ms
        fib(25)  # about 10ms
    start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")])
    return [b"Hello, world"]

これを次のような設定で nginx + uWSGI で動かします。

# nginx.conf
worker_processes 1;
pid nginx.pid;
error_log error.log;

events {
    worker_connections 768;
}

http {
    include mime.types;
    access_log access.log;

    upstream myapp {
        server 127.0.0.1:5000;
    }

    server {
        listen 8000 default_server;
        server_name _;

        location / {
            include uwsgi_params;
            uwsgi_pass myapp;
        }
    }
}
# uwsgi.ini
[uwsgi]
socket = 127.0.0.1:5000

master = 1
pidfile = uwsgi.pid
die-on-term = 1
lazy-app = 1
disable-logging=1

threads = 4

module=wsgi
callable=app

wrkを使って1, 2, 4並列で負荷をかけてみます。

$ wrk -t1 -c1 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 1 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   107.36ms  535.89us 112.24ms   96.77%
    Req/Sec     9.62      1.32    10.00     92.47%
  Latency Distribution
     50%  107.30ms
     75%  107.47ms
     90%  107.68ms
     99%  111.54ms
  186 requests in 20.03s, 36.69KB read
Requests/sec:      9.28
Transfer/sec:      1.83KB

$ wrk -t1 -c2 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 2 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   117.63ms    3.49ms 140.45ms   88.82%
    Req/Sec    17.43      4.36    20.00     74.47%
  Latency Distribution
     50%  116.27ms
     75%  116.54ms
     90%  124.00ms
     99%  129.20ms
  340 requests in 20.03s, 67.07KB read
Requests/sec:     16.98
Transfer/sec:      3.35KB

$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 4 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   231.09ms   27.29ms 315.45ms   66.86%
    Req/Sec    17.43      6.86    30.00     48.07%
  Latency Distribution
     50%  229.52ms
     75%  248.26ms
     90%  272.18ms
     99%  300.65ms
  344 requests in 20.03s, 67.86KB read
Requests/sec:     17.18
Transfer/sec:      3.39KB

2並列の場合はレスポンスタイムが1割ほど悪化しつつ、スループット(req/sec)は2倍弱に増えています。4並列ではスループットが2並列とほぼ同じで、それに応じてレスポンスタイムが悪化しています。

これは1並列時のレスポンスタイムのうち約50%がPythonのGILを必要としているためで、マルチスレッドでは2並列までしかスケールしません。

マルチプロセス

次にマルチプロセスでの特性を確認します。先ほどの設定の threads=4workers=4 に書き換えて、今度は4,8並列での結果をみていきます。

$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 4 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   108.95ms    3.10ms 142.10ms   88.95%
    Req/Sec    36.70      5.93    40.00     72.86%
  Latency Distribution
     50%  107.89ms
     75%  109.99ms
     90%  112.21ms
     99%  119.35ms
  733 requests in 20.04s, 144.60KB read
Requests/sec:     36.58
Transfer/sec:      7.22KB

$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   212.50ms    7.43ms 232.21ms   98.54%
    Req/Sec    38.17      5.99    40.00     90.16%
  Latency Distribution
     50%  212.84ms
     75%  213.05ms
     90%  213.28ms
     99%  219.39ms
  751 requests in 20.04s, 148.15KB read
Requests/sec:     37.48
Transfer/sec:      7.39KB

マルチスレッドと比較すると4並列まで綺麗にスケールしていることがわかります。

8並列にした場合はワーカー数が足りなくなり、平均レスポンスタイムが倍になっています。1つのlistenキューからacceptされたリクエストが順番に処理されるので、90%tileや99%tileのレスポンスタイムが平均から大きく乖離していないのは好ましい特性です。先ほどの4スレッドの1ワーカーに4並列でアクセスした場合に比べると、99%tileレスポンスタイムは2/3程度に抑えられています。

マルチプロセス * マルチスレッド

次はマルチプロセスとマルチスレッドを同時に利用します。

workers = 4
threads = 4

マルチプロセスの時と同じように4,8並列で負荷をかけてみます。

$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 4 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   185.46ms   50.06ms 298.03ms   60.70%
    Req/Sec    21.77      9.33    40.00     64.92%
  Latency Distribution
     50%  194.01ms
     75%  224.45ms
     90%  245.66ms
     99%  276.01ms
  430 requests in 20.03s, 84.82KB read
Requests/sec:     21.47
Transfer/sec:      4.24KB

$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   187.29ms   52.88ms 382.29ms   60.63%
    Req/Sec    42.60     14.37    80.00     67.34%
  Latency Distribution
     50%  189.82ms
     75%  229.73ms
     90%  254.79ms
     99%  298.24ms
  850 requests in 20.02s, 167.68KB read
Requests/sec:     42.45
Transfer/sec:      8.37KB

マルチプロセスの時と比べると8並列の時のスループットはよくなっているものの、99%tileのレスポンスタイムは逆に1.5倍に悪化しています。しかも4並列のときはレスポンスタイムだけでなくスループットも悪化しています。

これがプロセス間ロードバランスの問題です。各プロセスが最大4並列でリクエストを処理できるため、リクエストが1プロセスに集中する場合があり、99%tileのレスポンスタイムが1番最初に行った1プロセス4スレッドに4並列で負荷をかけた時の値に近づいてしまっているのです。

nginxを利用したプロセス間ロードバランス

この問題を解決するための方法として、ドキュメントの次のセクションで紹介されている方法がよさそうです。この方法とnginxのupstreamモジュールの機能を組み合わせてプロセス間ロードバランスを実現してみます。

Serializing accept(), AKA Thundering Herd, AKA the Zeeg Problem — uWSGI 2.0 documentation

次のように設定ファイルを書き換えて、uWSGIの各ワーカーがそれぞれ別のソケットをlistenし、nginxにそれらに対して least_conn でロードバランスさせます。

# uwsgi.ini
# ...
workers=4
threads=4

; ソケットを4つ作る
socket = 127.0.0.1:5000
socket = 127.0.0.1:5001
socket = 127.0.0.1:5002
socket = 127.0.0.1:5003

; ソケットをワーカーにマッピングする。ソケット番号は0から、ワーカー番号は1から始まることに注意。
map-socket = 0:1
map-socket = 1:2
map-socket = 2:3
map-socket = 3:4
# nginx.conf
# ...
    upstream myapp {
        server 127.0.0.1:5000;
        server 127.0.0.1:5001;
        server 127.0.0.1:5002;
        server 127.0.0.1:5003;
        least_conn;
    }
# ...

uWSGI起動時に次のようなログが出力され、各ソケットがそれぞれ別のワーカーにマッピングされていることがわかります。

spawned uWSGI master process (pid: 42729)
spawned uWSGI worker 1 (pid: 42730, cores: 4)
spawned uWSGI worker 2 (pid: 42731, cores: 4)
mapped socket 0 (127.0.0.1:5000) to worker 1
spawned uWSGI worker 3 (pid: 42732, cores: 4)
mapped socket 1 (127.0.0.1:5001) to worker 2
spawned uWSGI worker 4 (pid: 42733, cores: 4)
mapped socket 2 (127.0.0.1:5002) to worker 3
mapped socket 3 (127.0.0.1:5003) to worker 4

また4,8並列で負荷をかけてみます。

$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 4 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   108.78ms    2.52ms 122.69ms   84.51%
    Req/Sec    36.88      6.84    40.00     78.89%
  Latency Distribution
     50%  107.95ms
     75%  109.76ms
     90%  112.23ms
     99%  117.80ms
  736 requests in 20.03s, 145.19KB read
Requests/sec:     36.74
Transfer/sec:      7.25KB

$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
  1 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   135.41ms   12.10ms 186.63ms   76.30%
    Req/Sec    59.02     15.26    80.00     61.62%
  Latency Distribution
     50%  132.56ms
     75%  141.02ms
     90%  151.48ms
     99%  176.16ms
  1177 requests in 20.02s, 232.18KB read
Requests/sec:     58.79
Transfer/sec:     11.60KB

4並列ではマルチプロセスと同じ結果になり、8並列ではマルチプロセスの時よりもスループットも99%tileレスポンスタイムも大幅に改善しています。ロードバランスの問題が解決できていることがわかります。

一見理想的に見えますが、問題もあります。もし外部APIが詰まった場合はすべてのスレッドが詰まってしまうのですが、listenキューが1つの時は最初に空いたスレッドが次のリクエストを受け取れたのに、listenキューをバラバラにした場合は空いたスレッドが別のプロセスのlistenキューで待たされているリクエストを処理できないのです。

nginxのupstreamモジュールのドキュメントを読むと、 max_conns オプションで各upstreamサーバーへの同時接続数を制限して、 queue オプションで待ち行列を設定できるようです。これを使えば問題は解決できそうですが、残念ながら queue オプションはnginxの商用版でしか利用できないので今回は実験していません。

このオプションが利用できなくても、スレッド数にかなり余裕を持って設定しておき、かつCPU使用率が高くなったらコンテナやVMがスケールアウトするようにしておけば、リクエスト数が跳ね上がった場合にも外部APIが遅くなった場合にも十分に耐えられると思います。

さいごに

紹介した uWSGI ドキュメント内の記事では、thundering herd問題を解決するための方法として --thunder-lock オプションも紹介されています。しかしこのオプションではロードバランス問題は解決しません。プロセス間ロックがFIFOになっていても、最初に起動したワーカープロセスの全スレッドがそのロック待ち状態になってから次のワーカープロセスが起動してしまうので、4スレッドであれば最初の4リクエストが最初のワーカープロセスに集中してしまうのです。

今さらスレッド?と思われる方もいるかもしれませんが、asyncioを使ってもCPUのマルチコアを活かすためにはマルチプロセスが必要で同じようにロードバランス問題が発生するので、この記事の設定は参考になると思います。

また、フリースレッド版Pythonがあればシングルプロセスで済むという意見もあるかもしれません。完全にシングルスレッドにしてしまえば構成がシンプルになるのはいいのですが、GILが無くなっても代わりになる小粒度のロックは存在するので、アクセスが増えた時にロック競合が増えてしまいマルチスレッドだけではCPUコア数をフルに活用できない可能性があります。GILが無くなってもマルチプロセス構成のメリットが消えるわけではありません。

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の方がエラーが少ないかもしれません。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。