PythonでExponential Backoffをしたかったのでretryingモジュールを調べてみた

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

こんにちは植木和樹@上越妙高オフィスです。Lambda(Python)を使って様々なAPIを結びつけた処理を書いてます。

APIの呼び出しは一時的にエラーを返す場合があります。時間をあけて再試行すると成功することもあるので、適切なリトライ処理を検討しています。

Pythonで使えるモジュールはないかと探していたところ retrying を見つけました。

retrying を使うと以下のようなことができます。

  • リトライ回数の設定
  • リトライ間隔の設定
  • 例外や結果に応じたリトライするかどうかの設定

簡単に導入でき機能も十分のようだったので、いろいろ試してみました。

環境

  • python 2.7.14
  • retrying 1.3.3

パラメーター一覧

retrying の使い方はリトライさせたい関数に対して @retry というデコレータを付与するだけです。

この時@retryに様々なパラメータを与えることで挙動を変えることができます。

以降の元となるコードはこんな感じです。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import logging
import requests
import sys
import time
from retrying import retry, RetryError
from requests.exceptions import HTTPError

logging.basicConfig(level=logging.INFO)

class TestRunner(object):
    @retry(stop_max_delay=10000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response

if __name__ == "__main__":
    url = sys.argv[1]
    runner = TestRunner()
    try:
        response = runner.get(url)
        logging.info(response.headers)
    except HTTPError as e:
        logging.error(e)
    except RetryError as e:
        logging.error(e)
    except Exception as e:
        logging.critical(e)

stop_max_delay

最大何秒リトライを繰り返すかを指定できます。

下記のコードでは合計10秒(10,000ミリ秒)リトライを行います。

    @retry(stop_max_delay=10000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/503
INFO:root:1513051895.83  ... 1
INFO:root:1513051898.82  ... 2 (3秒経過)
INFO:root:1513051901.34  ... 3 (6秒経過)
INFO:root:1513051904.38  ... 4 (9秒経過)
ERROR:root:503 Client Error: Service Unavailable for url: http://getstatuscode.com/503

stop_max_attempt_number

最大何回リトライを繰り返すかを指定できます。

下記のコードでは合計10回リトライを行います。

    @retry(stop_max_attempt_number=10)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/503
INFO:root:1513052024.79  ...  1
INFO:root:1513052029.43  ...  2
INFO:root:1513052032.53  ...  3
INFO:root:1513052035.67  ...  4
INFO:root:1513052039.35  ...  5
INFO:root:1513052042.35  ...  6
INFO:root:1513052045.37  ...  7
INFO:root:1513052048.41  ...  8
INFO:root:1513052051.05  ...  9
INFO:root:1513052053.82  ... 10
ERROR:root:503 Client Error: Service Unavailable for url: http://getstatuscode.com/503

なおstop_max_attempt_number=0を指定しても、必ず1回は実行されます。

wait_fixed

リトライ間隔(秒)を指定します。

下記のコードは、3回のリトライを3秒間隔で行います。

    @retry(stop_max_attempt_number=3, wait_fixed=3000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/404
INFO:root:1513058511.72  ... 1
INFO:root:1513058515.0   ... 2 (3秒待機)
INFO:root:1513058518.26  ... 3 (3秒待機)
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

wait_exponential_multiplier, wait_exponential_max

1回目は2, 2回目は4, 3回目は8 ... とリトライ回数が増えるたびに、2のべき乗にリトライ間隔が延びていきます。

wait_exponential_maxwait_exponential_multiplierを設定した場合に、最大の待機時間を指定します。

下記のコードは、n回目のリトライ毎は 2 ^ n x 1000ミリ秒になります。 ただしwait_exponential_maxを指定しているため最大待機時間は 10秒 に抑えられます。

    @retry(stop_max_attempt_number=10, wait_exponential_multiplier=1000, wait_exponential_max=10000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/503
INFO:root:1513052344.98  ...  1
INFO:root:1513052347.06  ...  2 (2秒待機)
INFO:root:1513052351.16  ...  3 (4秒待機)
INFO:root:1513052359.55  ...  4 (8秒待機)
INFO:root:1513052369.62  ...  5 (10秒待機 ※16秒にはならない)
INFO:root:1513052379.69  ...  6 (10秒待機)
INFO:root:1513052389.77  ...  7 (10秒待機)
INFO:root:1513052399.85  ...  8 (10秒待機)
INFO:root:1513052409.92  ...  9 (10秒待機)
INFO:root:1513052420.03  ... 10 (10秒待機)
ERROR:root:503 Client Error: Service Unavailable for url: http://getstatuscode.com/503

retry_on_exception

例外が発生した際にここで指定した関数が呼び出され、関数がTrueを返す場合のみリトライが行われます。

例えば IOError など一部の例外が発生した場合のみリトライしたい時に使います。

下記のコードはアクセスしたURLのレスポンスコードが 503 の時のみリトライを3回繰り返します。

    def retry_if_503_httperror_occured(exception):
        return isinstance(exception, HTTPError) and exception.response.status_code == 503

    @retry(stop_max_attempt_number=3, wait_fixed=1000, retry_on_exception=retry_if_503_httperror_occured)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/503
INFO:root:1513054989.65
INFO:root:1513054997.01
INFO:root:1513055001.63
ERROR:root:503 Client Error: Service Unavailable for url: http://getstatuscode.com/503

retry_on_result

retry_on_exceptionと同様に、指定した関数が呼び出されます。関数がTrueを返す場合のみリトライが行われます。

先程の 503エラー を書き直すと下記になります。(例外を発生させず常にresponseを返すために response.raise_for_status() の処理を削っています)

    def retry_if_response_code_was_503(result):
        return result.status_code == 503

    @retry(stop_max_attempt_number=3, wait_fixed=1000, retry_on_result=retry_if_response_code_was_503)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        return response
$ ./test.py http://getstatuscode.com/503
INFO:root:1513055597.5
INFO:root:1513055603.48
INFO:root:1513055608.45
ERROR:root:503 Client Error: Service Unavailable for url: http://getstatuscode.com/503

wrap_exception

Trueにすると例外が発生した際に、元の例外をRetryExceptionにラップして再度raiseされます。

リトライ失敗を検知したい時はTrueにしておくのが良いでしょう。

wrap_exception=Falseの場合

$ ./test.py http://getstatuscode.com/404
INFO:root:1513057123.76
INFO:root:1513057127.99
INFO:root:1513057131.35
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

wrap_exception=Trueの場合

$ ./test.py http://getstatuscode.com/404
INFO:root:1513057072.5
INFO:root:1513057075.99
INFO:root:1513057079.85
ERROR:root:RetryError[Attempts: 3, Error:
  File "/Users/ueki.kazuki/retrying.py", line 200, in call
    attempt = Attempt(fn(*args, **kwargs), attempt_number, False)
  File "./test.py", line 19, in get
    raise response.raise_for_status()
  File "/Users/ueki.kazuki/requests/models.py", line 935, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
]

wait_random_min, wait_random_max

wait_random_minwait_random_max の間でランダムでリトライ間隔(ミリ秒)を指定します。

下記のコードは、5回のリトライを2〜5秒間隔で行います。

    @retry(stop_max_attempt_number=5, wait_random_min=2000, wait_random_max=5000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()
        return response
$ ./test.py http://getstatuscode.com/404
INFO:root:1513058683.52  ... 1
INFO:root:1513058688.06  ... 2 (4.5秒待機)
INFO:root:1513058693.13  ... 3 (5秒待機)
INFO:root:1513058699.35  ... 4 (4秒待機)
INFO:root:1513058702.91  ... 5 (3秒待機)
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

wait_incrementing_start, wait_incrementing_increment

リトライの度に固定でリトライ間隔が伸びていきます。

$ ./test.py http://getstatuscode.com/404
INFO:root:1513059042.89  ... 1
INFO:root:1513059044.22  ... 2 (1秒待機)
INFO:root:1513059047.5   ... 3 (3秒待機)
INFO:root:1513059052.76  ... 4 (5秒待機)
INFO:root:1513059060.02  ... 5 (7秒待機)
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

wait_jitter_max

通常の待機処理に加えて、ランダムミリ秒待機します。

固定秒数待機する wait_fixed と組み合わせて「最低○秒待つけど、待ち時間はランダムにばらけさせたい」といった用途に使えそうです。

    @retry(stop_max_attempt_number=5, wait_fixed=1000, wait_jitter_max=3000)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()

wait_jitter_max を指定しない場合

$ ./test.py http://getstatuscode.com/404
INFO:root:1513060641.64  ... 1
INFO:root:1513060642.89  ... 2 (1秒待機)
INFO:root:1513060644.15  ... 3 (1秒待機)
INFO:root:1513060645.41  ... 4 (1秒待機)
INFO:root:1513060646.66  ... 5 (1秒待機)
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

wait_jitter_max を指定した場合

$ ./test.py http://getstatuscode.com/404
INFO:root:1513060593.3   ... 1
INFO:root:1513060596.58  ... 2 (3秒待機 1+2)
INFO:root:1513060598.78  ... 3 (2秒待機 1+1)
INFO:root:1513060602.53  ... 4 (4秒待機 1+3)
INFO:root:1513060604.24  ... 5 (1.5秒待機 1*0.5) 
ERROR:root:404 Client Error: Not Found for url: http://getstatuscode.com/404

まとめ

retryingを使うことで下記のリトライを設定することができることがわかりました。

  • リトライ間隔
    • 固定 (wait_fixed)
    • 単純増加 (wait_incrementing)
    • ランダム (wait_random)
    • 指数 (wait_exponential)
    • 固定 (wait_fixed) + ランダム (wait_jitter_max)

結果、Lambdaで使うことを想定し 最大1分リトライ間隔は徐々に延ばす とした時のパラメータはこうしました。0+2+4+8+16+32 で6回処理が実施されます。

    @retry(stop_max_delay=60000, wait_exponential_multiplier=1000, wrap_exception=True)
    def get(self, url):
        logging.info(time.time())
        response = requests.get(url)
        if response.status_code != requests.codes.ok:
            raise response.raise_for_status()

AWSのAPIもスロットリングで一時的なエラーになる場合があります。SQSを用いた処理分散とともに、個々の処理についてもリトライを行うことで不要な例外通知を抑制することができるのではないでしょうか。