Blog
ブログ

2024年12月13日

requestsのプロキシによる例外処理記録

AI/BI部のcです。最近、requestsライブラリを使用している際に、プロキシが原因でいくつかの問題に遭遇しましたので、皆さんと共有したいと思います。

1.問題の現象

最近、プロジェクトに単体テストモジュールを追加していました。プロジェクトはHTTPバックエンドサービスであるため、テストフレームワークを実行する際にマルチプロセスを利用してバックエンドサービスを起動する方法を検討しました。これにより、テストフレームワークの実行中にサービスを起動してインターフェーステストを実施し、テストが終了した後にサービスを停止することができます。

しかし、テストフレームワークを実行してバックエンドサービスを起動した後、テストスクリプトが送信したHTTPリクエストに対して503エラーコードが返されました。503は「サービス利用不可」を意味するため、ブラウザで「http://127.0.0.1:8000/api/v1」 にアクセスしてみたところ、正常にレスポンスが返ってきました。

同じリクエスト先であるにもかかわらず、ブラウザではローカルのバックエンドサービスにアクセスできるのに、親プロセスのテストコードではアクセスできないのはなぜでしょうか。これはマルチプロセスの問題なのでしょうか、それともrequestsライブラリの制限なのでしょうか。

2.原因分析

同じアドレスに対する2つの異なるリクエスト方法の間には、具体的にどのような違いがあるのでしょうか。ここで、送信されたネットワークパケットを分析して、その違いを確認するためにネットワークキャプチャを考えました。traceroute 127.0.0.1を実行しても経路に違いはないように思われます。Wiresharkは確かに便利なツールですが、現在の環境では使用できません。そのため、HTTPAdapterDEBUGログレベルを利用して、低レベルでのアクセスに何か違いがあるかを確認する方法を思いつきました。

Python
import logging
import requests

logging.basicConfig(level=logging.DEBUG)

response = requests.get("http://127.0.0.1:8000/api/v1/")
print(response.text)
Python
DEBUG:urllib3.connectionpool:Starting new HTTP connection (1): 10.32.23.57:8899
Traceback (most recent call last):
  File "D:\py_workspaces\conda\envs\py38\lib\site-packages\urllib3\connection.py", line 158, in _new_conn
    conn = connection.create_connection(
  File "D:\py_workspaces\conda\envs\py38\lib\site-packages\urllib3\util\connection.py", line 80, in create_connection
    raise err
  File "D:\py_workspaces\conda\envs\py38\lib\site-packages\urllib3\util\connection.py", line 70, in create_connection
    sock.connect(sa)
TimeoutError: [WinError 10060]

ログを見て、直接アクセスしている対象が127.0.0.1ではなく、奇妙なアドレスであることが判明しました。そして、この環境にネットワークプロキシが設定されていることを思い出しました。環境変数にはHTTP_PROXYの設定がありました。これで疑問が解けました。プロキシの影響により、requestsは127.0.0.1に直接アクセスせず、プロキシを介して中継していました。しかし、プロキシのサービスアドレスからこの内部ネットワーク環境に戻ることができず、結果として503エラーが発生していたのです。

3.基本原理

そして、requestsがプロキシを使用する理由は、内部でurllib3というライブラリを使用しているためです。それでは、requestsのソースコードを見てみましょう。

Python
def request(self, method, url,
            params=None, data=None, headers=None, cookies=None, files=None,
            auth=None, timeout=None, allow_redirects=True, proxies=None,
            hooks=None, stream=None, verify=None, cert=None, json=None):
    
        # Create the Request.
        req = Request(
            method=method.upper(),
            url=url,
            headers=headers,
            files=files,
            data=data or {},
            json=json,
            params=params or {},
            auth=auth,
            cookies=cookies,
            hooks=hooks,
        )
        prep = self.prepare_request(req)

        proxies = proxies or {}

        settings = self.merge_environment_settings(
            prep.url, proxies, stream, verify, cert
        )

        # Send the request.
        send_kwargs = {
            'timeout': timeout,
            'allow_redirects': allow_redirects,
        }
        send_kwargs.update(settings)
        resp = self.send(prep, **send_kwargs)

        return resp

まずここでは、requestオブジェクトを作成する際に、proxyパラメータを確認します。proxyが指定されていない場合、空の辞書が設定に渡されます。

Python
def send(self, request, **kwargs):
        kwargs.setdefault('stream', self.stream)
        kwargs.setdefault('verify', self.verify)
        kwargs.setdefault('cert', self.cert)
        if 'proxies' not in kwargs:
            kwargs['proxies'] = resolve_proxies(
                request, self.proxies, self.trust_env
            )
...


def resolve_proxies(request, proxies, trust_env=True):
    proxies = proxies if proxies is not None else {}
    url = request.url
    scheme = urlparse(url).scheme
    no_proxy = proxies.get('no_proxy')
    new_proxies = proxies.copy()

    if trust_env and not should_bypass_proxies(url, no_proxy=no_proxy):
        environ_proxies = get_environ_proxies(url, no_proxy=no_proxy)

        proxy = environ_proxies.get(scheme, environ_proxies.get('all'))

        if proxy:
            new_proxies.setdefault(scheme, proxy)
    return new_proxies



def get_environ_proxies(url, no_proxy=None):
    if should_bypass_proxies(url, no_proxy=no_proxy):
        return {}
    else:
        return getproxies()

def getproxies():
    return getproxies_environment() or getproxies_registry()


def getproxies_environment():

    proxies = {}

    for name, value in os.environ.items():
        name = name.lower()
        if value and name[-6:] == '_proxy':
            proxies[name[:-6]] = value
            
    if 'REQUEST_METHOD' in os.environ:
        proxies.pop('http', None)
    for name, value in os.environ.items():
        if name[-6:] == '_proxy':
            name = name.lower()
            if value:
                proxies[name[:-6]] = value
            else:
                proxies.pop(name[:-6], None)
    return proxies

次に、proxyの処理がどのように進むかを追っていくと、最終的に getproxies_environment 関数に到達します。この関数では、osモジュールを使用して環境変数を取得します。そして、環境変数の中で名前に_proxyが含まれる部分が、後続のurllibで使用するproxyとして設定されます。

4.解決方法

このプロキシの問題を解決する方法は3つあります。

1つ目は、ソースコードで確認した通り、proxyパラメータを渡すことで、環境変数を取得する後続のステップをスキップできます。

Python
import requests

response = requests.get("http://127.0.0.1", proxies={})
print(response.text)

2つ目の方法は、環境変数を削除することです。

3つ目は、requests.Session()を使用することで、その後のリクエストでプロキシが使用されなくなります。

Python
import requests

session = requests.Session()

session.proxies = {}

response = session.get("http://127.0.0.1")
print(response.text)

5.おわりに

時々、奇妙な現象や自分の予想と一致しない現象に遭遇することがあります。しかし、冷静に対応し、基盤から分析を進めれば、必ず答えが見つかるものです。それでは、今回の問題解決の共有はここまでとします。

このページの先頭へ