Python | 自動化:リトライ処理

Python
スポンサーリンク

概要(リトライ処理は「一度コケても、もう一歩だけ踏み込む仕組み」)

リトライ処理は、

「一回失敗したからといって、すぐ諦めずに“もう一度やってみる”仕組み」

です。

特に自動化では、

  • ネットワークが一瞬だけ不安定になる
  • API サーバーが一時的に重い
  • 外部サービスが「今だけちょっと調子悪い」

といった「一時的な失敗」が本当に多いです。

ここで何の工夫もなく例外をそのまま投げると、
たった一瞬のエラーのせいでバッチ全体が落ちたり、夜中の処理が止まったりします。

逆に「何でもかんでも無限リトライ」すると、
壊れているサービスをいつまでも叩き続ける迷惑なクライアントになります。

だからこそ、

  • 何を失敗と見なすか
  • 何回まで再試行するか
  • 間隔をどうするか
  • 諦める条件は何か

をちゃんと決めて、コードに落としていくことが重要です。


リトライ処理の基本的な考え方(まず頭の中を整理する)

「一時的な失敗」と「根本的な失敗」を分けて考える

リトライすべきエラーと、すべきでないエラーがあります。

一時的な失敗の例は、

  • タイムアウト(たまたま遅かっただけ)
  • 一時的な接続エラー(ネットワークが瞬間的に切れた)
  • サーバー側の一時エラー(HTTP 503 など)

こういうものは、「もう1回やれば成功する可能性がある」ので、リトライの対象です。

一方、根本的な失敗の例は、

  • 認証エラー(APIキーやパスワードが間違っている:401/403)
  • そもそも存在しないリソースへのアクセス(404)
  • リクエストの形式が間違っている(400)

こういうものは、何回やり直しても同じ結果になります。
これはリトライしてはいけません。「すぐ諦めて、エラーとして報告する」が正しい動きです。

まずここを明確に頭の中で分けましょう。

「何回までやるか」と「どれくらい待つか」をセットで決める

リトライ設計のコアになる数字は、この2つです。

  • 最大リトライ回数(例:3回)
  • 各リトライの間隔(例:2秒、4秒、8秒…)

これを適当に決めると、すぐに破綻します。

例えば、

  • 1回目の失敗からすぐ 2回目 → まだサーバーが回復していない
  • 3回目まで全部 1秒間隔 → サーバー側からしたら立て続けに叩かれて迷惑

など。

現実的には、

  • 1〜3回程度のリトライ
  • 各回の間隔は少しずつ長くしていく(指数バックオフ)

という形がよく使われます。


例1:requests での HTTP リトライ処理(基本形)

一番シンプルな「固定回数+固定間隔」パターン

まずは、HTTP GET に対して「最大3回まで、2秒間隔でリトライ」という例から。

import time
import requests

def fetch_with_retry(url: str, timeout_sec: float = 5.0, retries: int = 3, delay_sec: float = 2.0):
    last_error = None

    for attempt in range(1, retries + 1):
        try:
            resp = requests.get(url, timeout=timeout_sec)
            resp.raise_for_status()
            return resp
        except (requests.Timeout, requests.ConnectionError) as e:
            print(f"[{attempt}/{retries}] 一時的なエラー: {e}")
            last_error = e
            if attempt == retries:
                break
            time.sleep(delay_sec)
        except requests.HTTPError as e:
            status = e.response.status_code
            print(f"HTTPエラー: {status}")
            if 500 <= status < 600:
                # サーバー側の一時エラーはリトライ対象にする場合が多い
                last_error = e
                if attempt == retries:
                    break
                time.sleep(delay_sec)
            else:
                # 4xx は多くの場合リトライ不要(設計にもよる)
                raise

    raise last_error
Python

ここで重要なポイントを丁寧に見ていきます。

一つ目は、「例外の種類でリトライ対象を分けている」ことです。
Timeout と ConnectionError は、たいてい一時的な問題なのでリトライ。
HTTPError のうち、500番台はサーバー側都合の一時エラーとしてリトライ対象にすることが多いです。

二つ目は、「最後までダメだったら、最後のエラーを上に投げる」構造にしていることです。
リトライしてダメだったことを、呼び出し側がきちんと知れるようにするのが大事です。

三つ目は、「リトライの間で sleep している」ことです。
失敗直後に連打すると、相手にも自分にも負荷がかかります。
少し間を空けるのがマナーでもあり、成功率を上げるコツでもあります。

指数バックオフで「だんだん間隔を伸ばす」パターン

もう少し実戦的な形として、「リトライごとに待ち時間を倍にしていく」書き方を見てみましょう。

def fetch_with_backoff(url: str, timeout_sec: float = 5.0, retries: int = 3, base_delay: float = 1.0):
    last_error = None
    delay = base_delay

    for attempt in range(1, retries + 1):
        try:
            resp = requests.get(url, timeout=timeout_sec)
            resp.raise_for_status()
            return resp
        except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as e:
            last_error = e
            print(f"[{attempt}/{retries}] エラー: {e}")
            if attempt == retries:
                break
            time.sleep(delay)
            delay *= 2  # 待ち時間を倍にする

    raise last_error
Python

これには意味があります。

  • 障害が発生してすぐは、少し間を空けてすぐ再試行したほうが、早く回復に追従できる
  • それでもダメなら、相手の復旧を待つために徐々に間隔を広げていく

という発想です。

特に、たくさんのクライアントが同じサーバーにリトライをかけるような状況では、
一斉に短い間隔で叩き続けると「復旧の邪魔」になってしまいます。


例2:asyncio+aiohttp での非同期リトライ処理

非同期処理では time.sleep を絶対に使わない

asyncio の世界では、time.sleep() を使うとイベントループが止まります。
代わりに await asyncio.sleep() を使わないといけません。

aiohttp と組み合わせた例を見てみます。

import asyncio
import aiohttp

async def fetch_json_with_retry(session: aiohttp.ClientSession, url: str,
                                timeout_sec: float = 5.0, retries: int = 3, base_delay: float = 1.0):
    last_error = None
    delay = base_delay

    for attempt in range(1, retries + 1):
        try:
            async with session.get(url, timeout=timeout_sec) as resp:
                resp.raise_for_status()
                return await resp.json()
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            last_error = e
            print(f"[{attempt}/{retries}] 一時的なエラー: {e}")
            if attempt == retries:
                break
            await asyncio.sleep(delay)
            delay *= 2

    raise last_error
Python

ここでもポイントは同じですが、非同期ならではの注意点があります。

一つ目は、「例外の型が aiohttp と asyncio 用になっている」ことです。
HTTP クライアントエラーは aiohttp.ClientError、タイムアウト系は asyncio.TimeoutError で捕まえます。

二つ目は、「待ちに await asyncio.sleep() を使っている」ことです。
これによって、リトライ待機中も他のコルーチンが動き続けることができます。

三つ目は、「この関数自体も async def である」ことです。
非同期処理全体の設計として、リトライロジックも非同期に溶け込む形にしておくと、
全体の整合性が取りやすくなります。


例3:汎用リトライデコレータで「ルールを再利用する」設計

同じリトライパターンをいろんな関数で使い回す

HTTP や DB やファイル I/O など、
あちこちで似たようなリトライ処理を書くと、コピペ地獄になります。

そういうときは、「リトライルールだけを切り出してデコレータにする」という手があります。

import time
from functools import wraps

def retry(exceptions, retries=3, base_delay=1.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            delay = base_delay
            for attempt in range(1, retries + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_error = e
                    print(f"[{attempt}/{retries}] エラー: {e}")
                    if attempt == retries:
                        break
                    time.sleep(delay)
                    delay *= 2
            raise last_error
        return wrapper
    return decorator
Python

これを使うと、

import requests

@retry((requests.Timeout, requests.ConnectionError), retries=3, base_delay=1.0)
def fetch_data(url):
    resp = requests.get(url, timeout=5)
    resp.raise_for_status()
    return resp.json()
Python

のように、「この関数はこういうエラーならリトライする」というポリシーを、
関数宣言のところにくっつけて書けます。

大事なのは次の2点です。

  • 「どの例外をリトライ対象にするか」を呼び出し側で決められること
  • 「リトライ回数」「待ち時間」を関数ごとに変えられること

これで、コードのあちこちに細かく散らばってしまうリトライロジックを、一つの場所に集約できます。


設計で本当に大事なところを深掘りする

ログと監視の観点:「リトライがどれくらい発生しているか」を見える化する

リトライ処理は、「動いているときほど存在を忘れられる」機能です。
でも、本番運用では「リトライがどれくらい発生しているか」がとても重要な指標になります。

例えば、

  • 1日あたりのリトライ回数が急に増えた
  • 特定の API だけリトライ率が異常に高い
  • 特定時間帯だけタイムアウト+リトライが多い

といった情報は、「どこに問題があるか」のヒントになります。

だから、print だけでなく logging などで、

  • 時刻
  • 対象(URLやコマンド)
  • 例外の種類
  • 何回目のリトライか

を残しておくと、あとから非常に効いてきます。

「無限リトライ」を絶対にやらない

たまに「とにかく成功するまでリトライすればいいじゃん」という発想がありますが、
自動化の世界ではほぼ確実に NG です。

理由はシンプルで、

  • 外部サービス側が完全に死んでいる場合、永遠に回り続けてリソースを食い潰す
  • 人間が介入するチャンス(アラート)を奪ってしまう
  • 「どこまでが想定内の失敗か」という境界が曖昧になる

からです。

最大回数(あるいは最大時間)を決めておき、
それを超えたら「失敗として認識させる」ことが、システムの健全さを守ります。

リトライすべきでない処理にリトライをかけない

もう一度強調しておきます。

すべての失敗にリトライをかけてはいけません。

具体例を少し具体的にします。

ユーザー入力が間違っている(バリデーションエラー)
→ 何度送っても同じなので、リトライは無意味

APIキーが無効(HTTP 401 / 403)
→ 設定ミスやセキュリティ問題なので、すぐに人間が気づくべき

存在しない ID にアクセス(HTTP 404)
→ 入力データかコードのバグ。リトライでは治らない

ファイルが存在しないエラー
→ パス間違い・事前処理の抜け漏れ。やはり人間や設計で直すべき

こういうものまでリトライの対象にしてしまうと、
「本当は設計や運用で直すべき問題」が、リトライによって隠されてしまいます。

リトライは「一時的な環境要因の揺らぎ」を吸収するためのものであって、
設計やデータの間違いを隠すためのものではありません。


まとめ(リトライ処理は「優しさ」ではなく「戦略」)

リトライ処理を、自動化の現場目線でまとめると、こうなります。

リトライすべきは「一時的な失敗」(タイムアウト・接続エラー・一部5xx)であり、
「根本的な失敗」(認証エラー・バリデーションエラーなど)はすぐに失敗として扱う。

最大回数と待ち時間(できれば指数バックオフ)を設計して、無限リトライは絶対にしない。

requests / aiohttp / subprocess / DB など、それぞれで「どの例外をリトライ対象にするか」を明確に決める。

リトライの発生状況をログに残し、「どこでどれくらい揺らいでいるか」を後から見えるようにする。

うまく設計されたリトライ処理は、「たまに転ぶけど、ちゃんと立ち上がって前に進み続ける」自動化を支える骨格になります。

タイトルとURLをコピーしました