Closed5

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

fujimotoshinjifujimotoshinji

Python アプリケーションで HTTP クライアントを実装する場合、標準ライブラリの urllib3 でもできますが、requests を使うことでより簡単に、より便利に実装できます。

https://requests.readthedocs.io/en/latest/

今回は requests を利用する際のエラーハンドリングについて調べてみました。

fujimotoshinjifujimotoshinji

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/")
fujimotoshinjifujimotoshinji

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 オブジェクトの使い方は以下を参照ください。

https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#urllib3.util.Retry

fujimotoshinjifujimotoshinji

タイムアウトによるハンドリング

デフォルト動作

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)"))

fujimotoshinjifujimotoshinji

Event Hooks を利用した任意のハンドリング

今まで紹介したハンドリングは決まったトリガーでリトライするもののみでした。
ここでは任意のハンドリングを実装する方法紹介します。
もちろんリクエスト単位で try-except や if でハンドリング可能ですが、ここでは Session レベルで共通処理の Event Hooks を用いた実装を紹介します。

Event Hooks

requests は Event Hooks という機能でイベントに応じて関数を実行できます。
といっても現在は HTTP レスポンスに対する Event しか対応していません。

https://requests.readthedocs.io/en/latest/user/advanced/#event-hooks

以下はエラーメッセージに応じて任意の 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/")
このスクラップは2022/08/31にクローズされました