2024年12月13日
AI/BI部のcです。最近、requestsライブラリを使用している際に、プロキシが原因でいくつかの問題に遭遇しましたので、皆さんと共有したいと思います。
最近、プロジェクトに単体テストモジュールを追加していました。プロジェクトはHTTPバックエンドサービスであるため、テストフレームワークを実行する際にマルチプロセスを利用してバックエンドサービスを起動する方法を検討しました。これにより、テストフレームワークの実行中にサービスを起動してインターフェーステストを実施し、テストが終了した後にサービスを停止することができます。
しかし、テストフレームワークを実行してバックエンドサービスを起動した後、テストスクリプトが送信したHTTPリクエストに対して503エラーコードが返されました。503は「サービス利用不可」を意味するため、ブラウザで「http://127.0.0.1:8000/api/v1」
にアクセスしてみたところ、正常にレスポンスが返ってきました。
同じリクエスト先であるにもかかわらず、ブラウザではローカルのバックエンドサービスにアクセスできるのに、親プロセスのテストコードではアクセスできないのはなぜでしょうか。これはマルチプロセスの問題なのでしょうか、それともrequests
ライブラリの制限なのでしょうか。
同じアドレスに対する2つの異なるリクエスト方法の間には、具体的にどのような違いがあるのでしょうか。ここで、送信されたネットワークパケットを分析して、その違いを確認するためにネットワークキャプチャを考えました。traceroute 127.0.0.1
を実行しても経路に違いはないように思われます。Wireshark
は確かに便利なツールですが、現在の環境では使用できません。そのため、HTTPAdapter
のDEBUG
ログレベルを利用して、低レベルでのアクセスに何か違いがあるかを確認する方法を思いつきました。
import logging
import requests
logging.basicConfig(level=logging.DEBUG)
response = requests.get("http://127.0.0.1:8000/api/v1/")
print(response.text)
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エラーが発生していたのです。
そして、requests
がプロキシを使用する理由は、内部でurllib3
というライブラリを使用しているためです。それでは、requests
のソースコードを見てみましょう。
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
が指定されていない場合、空の辞書が設定に渡されます。
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
として設定されます。
このプロキシの問題を解決する方法は3つあります。
1つ目は、ソースコードで確認した通り、proxy
パラメータを渡すことで、環境変数を取得する後続のステップをスキップできます。
import requests
response = requests.get("http://127.0.0.1", proxies={})
print(response.text)
2つ目の方法は、環境変数を削除することです。
3つ目は、requests.Session()
を使用することで、その後のリクエストでプロキシが使用されなくなります。
import requests
session = requests.Session()
session.proxies = {}
response = session.get("http://127.0.0.1")
print(response.text)
時々、奇妙な現象や自分の予想と一致しない現象に遭遇することがあります。しかし、冷静に対応し、基盤から分析を進めれば、必ず答えが見つかるものです。それでは、今回の問題解決の共有はここまでとします。