[Python] requests のエラーハンドリング

Python アプリケーションで HTTP クライアントを実装する場合、標準ライブラリの urllib3 でもできますが、requests を使うことでより簡単に、より便利に実装できます。
今回は requests を利用する際のエラーハンドリングについて調べてみました。

requests の基本
HTTP リクエスト
HTTP リクエストは requests モジュールの HTTP メソッドに応じた関数から送信できます。
import requests
## GET の場合
requests.get("https://example.com/")
## POST の場合
requests.post("https://example.com/")
Session 作成
HTTP クライアントを使いまわしたい(ConnectionPool)場合、Session オブジェクトを生成し、Session オブジェクトから HTTP リクエストを送信します。
import requests
session = requests.Session()
session.get("https://example.com/")

HTTP レスポンスステータスコードによるハンドリング
デフォルト動作
HTTP レスポンスステータスコードは 400-499 がクライアントエラー、 500-599 がサーバーエラーとなります。
requests はデフォルト動作ではステータスコードが何であろうと Exception を raise しません。
response = requests.get("http://localhost:8000/status500/")
print(response.status_code)
## 実行結果
500
適当にステータスコード 500を返す HTTP サーバを立てています。
ステータスコードがエラーの時に Exception を raise
Response オブジェクトの raise_for_status メソッドをコールすることで 400-599 の時に HTTPError を raise します。
response = requests.get("http://localhost:8000/status500/")
response.raise_for_status()
print(response.status_code)
## 実行結果
---------------------------------------------------------------------------
HTTPError Traceback (most recent call last)
/var/folders/81/q2s3xkvj46zd6j690n1cqjpc0000gq/T/ipykernel_32822/745156964.py in <module>
1 response = requests.get("http://localhost:8000/status500/")
----> 2 response.raise_for_status()
3 print(response.status_code)
~/Techs/python/python3-sandbox/.venv/lib/python3.10/site-packages/requests/models.py in raise_for_status(self)
951
952 if http_error_msg:
--> 953 raise HTTPError(http_error_msg, response=self)
954
955 def close(self):
HTTPError: 500 Server Error: Internal Server Error for url: http://localhost:8000/status500/
任意のステータスコードの時にリトライする
HTTP ステータスコードに応じて無条件にリトライしたいケースもあります。
以下は 429, 500, 503, 504 を返した時にリトライする実装例です。
retry = requests.adapters.Retry(status_forcelist=[429, 500, 503, 504])
session = requests.Session()
session.mount("http://", requests.adapters.HTTPAdapter(max_retries=retry))
response = requests.get("http://localhost:8000/status500/")
response.raise_for_status()
print(response.status_code)
## 実行結果
(略)
RetryError: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /status500/ (Caused by ResponseError('too many 500 error responses'))
Retry オブジェクトの使い方は以下を参照ください。

タイムアウトによるハンドリング
デフォルト動作
HTTP リクエストのタイムアウトは Connection タイムアウトと Read タイムアウトがあります。
requests はデフォルト動作ではタイムアウトしません。
Connection タイムアウト
macOS の場合、75秒で OS レベルのタイムアウトが発生します。
response = requests.get("http://10.255.255.1/")
## 実行結果
(略)
ConnectTimeout: HTTPConnectionPool(host='10.255.255.1', port=80): Max retries exceeded with url: / (Caused by ConnectTimeoutError(<urllib3.connection.HTTPConnection object at 0x110cc15a0>, 'Connection to 10.255.255.1 timed out. (connect timeout=None)'))
Read タイムアウト
HTTP サーバがレスポンス、もしくはタイムアウトを返さない限り、待ち続けます。
response = requests.get("http://localhost:8000/sleep/10000")
タイムアウト設定
## Connection/Read 共通の値
response = requests.get("http://example.com/", timeout=10)
## Connection/Read を別の値
response = requests.get("http://example.com/", timeout=(30, 10))
Connection タイムアウト
1つ目の 10秒でタイムアウトが発生します。
response = requests.get("http://10.255.255.1/", timeout=(10, 1))
## 実行結果
(略)
ConnectTimeout: HTTPConnectionPool(host='10.255.255.1', port=80): Max retries exceeded with url: / (Caused by ConnectTimeoutError(<urllib3.connection.HTTPConnection object at 0x1102a35b0>, 'Connection to 10.255.255.1 timed out. (connect timeout=10)'))
Read タイムアウト
2つ目の 5秒でタイムアウトが発生します。
response = requests.get("http://localhost:8000/sleep/10000", timeout=(10, 5))
## 実行結果
(略)
ReadTimeout: HTTPConnectionPool(host='localhost', port=8000): Read timed out. (read timeout=5)
タイムアウトの時にリトライする
HTTP サーバの一時的な問題でタイムアウトが発生する場合があります。
一時的な問題に備えて、タイムアウト時にリトライを設定できます。
以下の場合、3秒の Read タイムアウトを 2回再試行するので 6秒でタイムアウト+リトライ回数上限となります。
retry = requests.adapters.Retry(connect=3, read=2)
session = requests.Session()
session.mount("http://", requests.adapters.HTTPAdapter(max_retries=retry))
response = session.get("http://localhost:8000/sleep/10000", timeout=(10, 3))
response.raise_for_status()
print(response.status_code)
## 実行結果
(略)
ConnectionError: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /sleep/10000 (Caused by ReadTimeoutError("HTTPConnectionPool(host='localhost', port=8000): Read timed out. (read timeout=3)"))

Event Hooks を利用した任意のハンドリング
今まで紹介したハンドリングは決まったトリガーでリトライするもののみでした。
ここでは任意のハンドリングを実装する方法紹介します。
もちろんリクエスト単位で try-except や if でハンドリング可能ですが、ここでは Session レベルで共通処理の Event Hooks を用いた実装を紹介します。
Event Hooks
requests は Event Hooks という機能でイベントに応じて関数を実行できます。
といっても現在は HTTP レスポンスに対する Event しか対応していません。
以下はエラーメッセージに応じて任意の Exception を raise する Event Hooks の実装例です。
def custom_hooks(r: requests.Response, *args, **kwargs):
body = r.json()
if body["message"] == "error":
raise ValueError
return r
session = requests.Session()
session.hooks["response"] = [custom_hooks]
response = session.get("http://localhost:8000/error")
## 実行結果
(略)
ValueError:
アクセストークンを再取得する Event Hooks
Rest API をコールする際に OAuth2 でアクセスするのはよくあるケースです。
認可サーバの負荷を下げるためにアクセストークンを使い回してリクエストしたりします。
アクセストークンは有効期限があり、有効期限が切れた場合、再度取得する必要があります。
以下はアクセストークンが切れていた際に再取得する Event Hooks の実装例です。
Client Credentials Flow を想定しています。
def get_access_token(client_id: str, client_secret: str):
token_endpoint = "https://example.com/token_endpoint"
response = requests.post(token_endpoint, params={"grant_type": "client_credentials"}, auth=(client_id, client_secret))
return response.json()["access_token"]
def get_login_session(client_id: str, client_secret: str) -> requests.Session:
def expire_access_token_hook(response: requests.Response, *args, **kwargs):
if response.status_code == 401:
new_access_token = get_access_token(client_id, client_secret)
request = response.request
session.headers["Authorization"] = f"Bearer {new_access_token}"
request.headers["Authorization"] = f"Bearer {new_access_token}"
response = session.send(request)
return response
access_token = get_access_token(client_id, client_secret)
session = requests.Session()
session.headers["Authorization"] = f"Bearer {access_token}"
session.headers["Content-Type"] = "application/json"
session.hooks["response"].append(expire_access_token_hook)
return session
session = get_login_session(client_id, client_secret)
session.get("https://resource.example.com/")