API・データベース ·

外部APIのタイムアウトとリトライ設計 — 再送してよい失敗の見分け方

  • API設計
  • 外部API連携
  • Laravel

この記事で扱う問題

外部 API の呼び出しは、自分のシステムの中で一番「待たされる」処理です。タイムアウトとリトライを何となく決めていると、例えば次のようなことが起きます。

  • 相手の API が応答しなくなり、呼び出し側の PHP-FPM のプロセスやキューのワーカーが、すべて応答待ちで埋まる。相手の障害が、自分のサービス全体の障害になる
  • タイムアウトしたので決済の API をリトライしたら、1回目も相手側では処理されていて、二重に課金された
  • 相手が一時的に遅くなっただけなのに、全クライアントが即座にリトライし、相手の負荷がさらに上がって復旧しない

タイムアウトは「どれだけ待つか」、リトライは「失敗をどう扱うか」の設計です。どちらも、相手の API だけ見ていては決められず、呼び出し側にどれだけの時間の余裕があるかと、その操作を2回実行してよいかから決めます。

判断

タイムアウトは、接続と全体を分けて必ず指定する

Laravel の HTTP クライアント(内部は Guzzle)では、connectTimeout() が接続までの上限、timeout() が接続を含めたリクエスト全体の上限です。Laravel の既定値(接続 10 秒・全体 30 秒)に任せず、呼び出しごとに指定します。

値は次の順で決めます。

  1. 呼び出し側の予算:利用者が画面の前で待っているなら、画面の応答全体で何秒まで許されるか。キューのジョブなら、ジョブの $timeout の中に、リトライも含めて収まるか。ロードバランサや Web サーバーのタイムアウトより短いか
  2. 相手の普段の応答時間:ログにある所要時間の分布を見て、普段の遅い側(p99 など)より少し長くする。短すぎると、正常な遅い応答まで失敗にしてしまう

予算が相手の普段の応答時間より短いなら、タイムアウトの値を調整しても解決しません。利用者を待たせずに受け付けだけ返し、呼び出しはキューに回す、という設計の変更が必要です。

失敗の種類で、リトライしてよいかを分ける

失敗の種類 相手で処理された可能性 リトライ
4xx(400・401・404・422 など) ない(拒否された) しない。何度送っても同じ結果になる
408(Request Timeout)・409(同じキーを処理中) ない、または処理中 する。一時的な状態なので、間隔を空けて同じキーで送る
429(Too Many Requests) ない する。Retry-After があれば、その時間だけ待つ
5xx ある(処理の途中で失敗したかもしれない) 冪等な操作だけする
接続の失敗・タイムアウト ある 冪等な操作だけする

409 は API によって意味が違います。Idempotency Key に対応した API の多くは、同じキーのリクエストがまだ処理中のときに 409 を返します。タイムアウトのあとに同じキーでリトライすると、まさにこの状況が起きるので、拒否ではなく一時的な失敗として扱います。409 が「すでに存在する」などの別の意味の API なら、4xx と同じく拒否として扱います。

一番注意が必要なのは、タイムアウトです。タイムアウトは「失敗した」ではなく「結果が分からない」です。リクエストは相手に届いていて、応答を返す前にこちらが待つのをやめただけかもしれません。

Laravel の HTTP クライアントでは、接続できなかった場合も、送ったあと応答を待つ間にタイムアウトした場合も、同じ ConnectionException になります。例外の種類だけでは「送っていない」と言い切れないので、どちらも「処理された可能性がある」として扱います。

そのうえで、リトライしてよい操作を決めます。

  • 参照(GET)や、同じ値での上書き:もともと冪等なので、そのままリトライしてよい
  • 作成・課金・送信(POST):相手が Idempotency Key に対応していれば、同じキーを付けてリトライする。対応していなければ、自動ではリトライせず「要確認」として残す

分類は Gateway に閉じ込め、リトライの方針は呼び出し側が決める

HTTP のステータスや例外の種類は、業務の側から見れば「外部 API の都合」です。そこで、失敗の分類は外部 API ごとの Gateway(Infrastructure)に閉じ込め、業務の側には「一時的な失敗」「拒否された」「混み合っている」という意味の例外だけを返します。

use RuntimeException;

final class ShippingTemporarilyUnavailable extends RuntimeException
{
}

final class ShippingRequestRejected extends RuntimeException
{
}

final class ShippingRateLimited extends RuntimeException
{
    public function __construct(public readonly int $retryAfterSeconds)
    {
        parent::__construct("rate limited, retry after {$retryAfterSeconds}s");
    }
}

interface ShippingGateway
{
    /**
     * @throws ShippingTemporarilyUnavailable 結果が分からない・一時的な失敗。同じキーでリトライしてよい
     * @throws ShippingRateLimited            混み合っている。指定の時間を待ってリトライする
     * @throws ShippingRequestRejected        拒否された。リトライしても結果は変わらない
     */
    public function requestShipment(OrderId $orderId, string $idempotencyKey): void;
}

実装は、1回だけ呼び出して結果を分類します。ここではリトライしません。

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\Response;

final class HttpShippingGateway implements ShippingGateway
{
    public function __construct(
        private Factory $http,
        private string $baseUrl,
        private string $token,
    ) {
    }

    public function requestShipment(OrderId $orderId, string $idempotencyKey): void
    {
        try {
            $response = $this->http
                ->baseUrl($this->baseUrl)
                ->withToken($this->token)
                ->withHeaders(['Idempotency-Key' => $idempotencyKey])
                ->connectTimeout(2)
                ->timeout(5)   // 接続を含めた全体の上限
                ->post('/shipments', ['order_id' => $orderId->value]);
        } catch (ConnectionException $e) {
            // 接続できなかったのか、届いたあとに応答を待ちきれなかったのかは区別できない
            throw new ShippingTemporarilyUnavailable($e->getMessage(), previous: $e);
        }

        if ($response->successful()) {
            return;
        }

        if ($response->status() === 429) {
            throw new ShippingRateLimited($this->retryAfterSeconds($response));
        }

        // 409 は、同じ Idempotency Key のリクエストを相手がまだ処理中という意味(相手の仕様書で確かめる)
        if (in_array($response->status(), [408, 409], true) || $response->serverError()) {
            throw new ShippingTemporarilyUnavailable("shipping api returned {$response->status()}");
        }

        throw new ShippingRequestRejected("shipping api rejected: {$response->status()}");
    }

    private function retryAfterSeconds(Response $response): int
    {
        $value = $response->header('Retry-After');

        // Retry-After は秒数のほかに日時の形式もある。ここでは秒数だけ読み、それ以外は既定の60秒にする
        return ctype_digit($value) ? max(1, (int) $value) : 60;
    }
}

Factory はコンストラクタで受け取ります(Facade の Http でも同じことはできます)。$baseUrl と $token は設定値なので、サービスプロバイダで ShippingGateway に HttpShippingGateway を束縛するときに config() から渡す前提です(省略)。テストでは Http::fake() と同じ仕組みで、タイムアウトや 5xx を返させて分類を確かめられます。

待てる処理のリトライは、キューに任せる

出荷の依頼は利用者を待たせる必要がないので、キューのジョブで呼び出し、リトライもキューに任せます。キーは、ジョブの UUID ではなく「この注文の出荷依頼」という操作から決めます。

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Throwable;

final class RequestShipmentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 6;

    public int $timeout = 20;   // 1回の呼び出し(最大5秒)より十分長く、retry_after より数秒以上短く

    public function __construct(public readonly string $orderId)
    {
    }

    /** @return list<int> */
    public function backoff(): array
    {
        // 間隔を指数的に延ばし、ジョブごとに少しずらす(ジッター)
        return array_map(
            fn (int $seconds): int => $seconds + random_int(0, intdiv($seconds, 4)),
            [10, 30, 90, 270, 600],
        );
    }

    public function handle(ShippingGateway $shipping): void
    {
        try {
            $shipping->requestShipment(
                new OrderId($this->orderId),
                idempotencyKey: "shipment-request:{$this->orderId}",
            );
        } catch (ShippingRateLimited $e) {
            $this->release($e->retryAfterSeconds);   // 相手が指定した時間だけ待つ
        } catch (ShippingRequestRejected $e) {
            $this->fail($e);                         // 何度送っても同じなので、リトライを使い切らせない
        }
        // ShippingTemporarilyUnavailable はそのまま投げ、backoff() の間隔でリトライさせる
    }

    public function failed(Throwable $e): void
    {
        // 拒否された依頼や、リトライを使い切った依頼を、人が確認する一覧に残す(省略)
    }
}

backoff() の値はディスパッチのときに計算されてジョブに記録されるので、ジョブごとに少しずつ違う間隔になります。障害が復旧した瞬間に、たまっていたジョブが同じ時刻にいっせいに再送するのを避けるためです。なお、release() も試行回数に数えられるので、429 が続く相手なら $tries を多めにするか、retryUntil() で期限を決めます。

利用者が画面の前で待っている参照(送料の見積もりなど)は、キューに回せません。その場合は、短いタイムアウトで多くても1回だけリトライし、それでも失敗したら「いまは表示できない」と返します。画面の応答の予算の中で、何度も待つ余裕はないからです。

なぜそう判断したか

「失敗したら全部リトライする」を選ばなかった理由

HTTP クライアントのリトライ機能に回数だけ指定すると、4xx も 5xx もタイムアウトも同じように再送されます。4xx の再送は無駄なだけですが、課金や作成の API へのタイムアウト後の再送は、二重実行を起こします。リトライの判断には「その失敗が何を意味するか」と「その操作を2回実行してよいか」の両方が必要で、前者は Gateway、後者は呼び出す側の業務が知っています。分類を Gateway に置き、リトライの方針を呼び出し側(ここではジョブ)に置いたのはそのためです。

Gateway の中でリトライしない理由

Gateway の中で3回リトライし、それをキューがさらに6回リトライすると、相手には最大18回届きます。複数の層がそれぞれリトライすると、回数は掛け算で増え、相手が弱っているときほど負荷をかけます。リトライする層は1つに決めます。待てる処理はキュー、待てない処理はその場で最小限、という分け方です。

固定間隔ではなく、指数バックオフとジッターにした理由

相手が一時的に過負荷になっているとき、固定の短い間隔で再送すると、回復しかけた相手を再び押しつぶします。間隔を指数的に延ばせば、短い障害は最初の数回で乗り切り、長い障害では相手に負荷をかけ続けずに済みます。ジッターがないと、同じ時刻に失敗したジョブが、同じ時刻にそろって再送します。

タイムアウトは長めのほうが安全、とは限らない理由

長いタイムアウトは「相手が遅いときに失敗しにくい」ように見えますが、その間、呼び出し側のプロセスは何もせずに埋まります。相手の障害のときに、待っているプロセスが増え続け、無関係な画面まで応答しなくなります。障害の調査でも、どの層で時間を使っているかが見えにくくなります。タイムアウトは、相手の障害を自分のサービスの中に広げないための上限です。

どこから設計過剰になるか

  • 社内の管理画面から、まれに呼ぶだけの参照 API なら、タイムアウトを指定して失敗をそのまま表示するだけで十分です。リトライも例外の分類も要りません
  • 外部 API が1つで、呼び出す箇所も1か所なら、Gateway の Interface と例外の階層まで作らず、ジョブの中で分類しても構いません。呼び出し箇所が増えてから切り出します
  • サーキットブレーカー(失敗が続いたら一定時間呼び出しをやめる仕組み)は、相手の障害が自分のサービス全体に響くほど呼び出しが多い場合に検討します。私自身もまだ深めている途中の領域ですが、タイムアウトとリトライの層の整理ができていない段階で入れても、効果を判断しにくいと考えています
  • 相手が Idempotency Key に対応しておらず、作成の API しかない場合、完全な解決はありません。タイムアウトした依頼は自動でリトライせず、相手の管理画面や照会 API で確かめてから処理する運用を選びます

まとめ

  • タイムアウトは接続と全体を分けて必ず指定し、呼び出し側の予算と相手の普段の応答時間から決める
  • タイムアウトと 5xx は「結果が分からない」。冪等な操作か、Idempotency Key を付けた操作だけリトライする
  • 失敗の分類は Gateway に、リトライは1つの層(待てる処理はキュー)に置き、指数バックオフとジッターで間隔を空ける

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。