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))
このブログに乗せているコードは引用を除き CC0 1.0 で提供します。