PythonでExponential Backoffをしたかったのでretryingモジュールを調べてみた
はじめに
こんにちは植木和樹@上越妙高オフィスです。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_max は wait_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_min 〜 wait_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を用いた処理分散とともに、個々の処理についてもリトライを行うことで不要な例外通知を抑制することができるのではないでしょうか。