再接続とリトライ: 指数バックオフ、ジッター、Retry-After | eSeGeCe ブログ

再接続とリトライ: 指数バックオフ、ジッター、Retry-After

· コンポーネント

1万台のクライアントが接続している WebSocket サーバーを再起動すると、不愉快なことが起こります。すべてのクライアントがほぼ同じ瞬間に切断に気づき、すべてのクライアントが同じ固定秒数だけ待ち、すべてのクライアントが同じタイミングで再ダイヤルします。サーバーは起動した直後に1万件の同時 TLS ハンドシェイクを浴びます。サーバーは倒れるか、スロットリングし、クライアントたちはまた足並みをそろえてリトライします。これがサンダリングハード (雪崩現象) であり、固定の再接続間隔はそれを確実に引き起こします。

sgcWebSockets 2026.7 は、すでに苦しんでいるサーバーをクライアントが叩き続ける2つの箇所に対処します。WatchDog は固定間隔ではなく指数バックオフとジッターで再接続できるようになり、HTTP クライアントは同じバックオフの計算式を使い、サーバーの Retry-After のヒントを尊重しながら失敗したリクエストを自分でリトライできるようになりました。どちらもオプトインで既定は無効なので、有効にするまで既存のアプリケーションはこれまでとまったく同じように動作します。

固定の再接続間隔が抱える問題

WatchDog はこれまでずっと、クライアントを接続状態に保つための標準的な手段でした。Enabled を設定し、Interval を秒単位で設定すると、接続が切れたときにコンポーネントは復帰するまで Interval 秒ごとにリトライします。

不安定な Wi-Fi 回線につながった1台のクライアントであれば、これで問題ありません。しかし多数の端末群にとっては誤った振る舞いです。固定間隔では、群れの中のすべてのクライアントが同じ周期で刻むメトロノームになり、サーバーの再起動がそれらすべてを同期させます。全員が同じ瞬間に切断されたので、全員が同じ瞬間に再接続し、それが永遠に続きます。さらに悪いことに、間隔は適応しません。サーバーが10分間ダウンしていれば、Interval = 1 のクライアントは600回の無意味な接続試行を行い、他の全員も同じことをします。

望ましいのはその逆です。ほとんどの切断は一時的なもので、素早い再接続はユーザーには見えないので、最初は素早く再試行する。そのあとバックオフして、本当にダウンしているサーバーを叩き続けないようにする。そしてクライアントを分散させて、全員が同時に到着しないようにする。

WatchDog の指数バックオフ

WatchDog のオプションオブジェクトに4つのプロパティが追加されました。型は sgcTCP_Classes にあります。

type
  TsgcWatchDogBackoff = (wdbFixed, wdbExponential);

どの WebSocket コンポーネントでも、オプションには Client.WatchDog でアクセスでき、バックオフの設定は次のとおりです。

uses
  sgcWebSocket, sgcTCP_Classes;

begin
  sgcWebSocketClient1.WatchDog.Enabled           := True;
  sgcWebSocketClient1.WatchDog.Attempts          := 0;     // 0 = keep trying forever
  sgcWebSocketClient1.WatchDog.Interval          := 1;     // seconds, the first delay
  sgcWebSocketClient1.WatchDog.Backoff           := wdbExponential;
  sgcWebSocketClient1.WatchDog.BackoffMultiplier := 2.0;   // double it every attempt
  sgcWebSocketClient1.WatchDog.MaxInterval       := 60;    // seconds, the ceiling
  sgcWebSocketClient1.WatchDog.Jitter            := 0.2;   // up to 20% random spread
end;

計算はあえて退屈なものにしてあります。最初の再接続試行は Interval だけ待ちます。以降の試行では直前の遅延に BackoffMultiplier を掛け、その結果を MaxInterval でクランプします。上の値なら、遅延は 1秒、2秒、4秒、8秒、16秒、32秒、60秒、60秒、60秒... と進みます。クライアントが再び接続されるとカウンターはただちにリセットされるので、短いネットワークの瞬断のコストは1分ではなく1秒です。

IntervalMaxInterval はどちらも、WatchDog が従来からそうであったように秒単位で表されます。MaxInterval が 0 の場合は上限なしを意味し、これが既定値です。指数バックオフを有効にするなら上限を設定してください。そうしないと遅延は時間単位になるまで倍増し続けます。

既存ユーザーにとって重要な点は、Backoff の既定値が wdbFixed であることです。アップグレードして何も変更しなければ、クライアントは従来どおりの固定間隔で再接続します。遅延の計算が使われるのは wdbExponential に切り替えたときか Jitter を設定したときだけで、その計算は HTTP のリトライが使うのと同じヘルパー、sgcBase_HelperssgcNextBackoffMs です。

ジッター、そしてそれが大規模環境で重要な理由

指数バックオフだけでは群れは崩れません。群れの到着頻度を下げるだけです。全員が1秒待ち、次に2秒、次に4秒待つ1万台のクライアントは、依然として一斉に到着する1万台のクライアントであり、ただ波が大きくなっていくだけです。

実際に分散させるのが Jitter です。これは計算された遅延に対する割合で、クライアントごと、試行ごとにランダムに適用されます。Jitter = 0.2 なら、公称8秒の遅延は8秒を中心とする20%の幅の中のランダムな値になるので、クライアント A は 7.1 秒で、クライアント B は 8.6 秒で、クライアント C は 8.0 秒で目を覚まします。端末群はスパイクではなく、なだらかに広がった形で到着し、サーバーの accept キューが追いつけるようになります。

// A conservative fleet profile: fast first retry, hard ceiling, generous spread.
sgcWebSocketClient1.WatchDog.Enabled           := True;
sgcWebSocketClient1.WatchDog.Interval          := 2;
sgcWebSocketClient1.WatchDog.Backoff           := wdbExponential;
sgcWebSocketClient1.WatchDog.BackoffMultiplier := 1.5;   // gentler than doubling
sgcWebSocketClient1.WatchDog.MaxInterval       := 120;
sgcWebSocketClient1.WatchDog.Jitter            := 0.5;   // spread over half the delay

ジッターはバックオフのモードとは独立しています。BackoffwdbFixed のままにして Jitter だけを設定することもでき、その場合はクライアントの同期が崩れた古典的な固定間隔になります。それだけで十分なことも多く、既存のアプリケーションに対する最小限の変更で済みます。

HTTP リクエストの自動リトライ

この機能のもう半分は HTTP 側です。サーバーが再デプロイ中で 503 が返る、あるいはレート制限を超えて 429 が返る、といった理由で失敗した1回の HTTP リクエストは、本当はエラーではありません。それは少し待ってから再送すべきリクエストです。そのループを手書きするのは面倒で、しかも誰もが少しずつ間違えて書きます。

TsgcIdHTTP には RetryOptions オブジェクトが追加されました。sgcHTTP_ClientTsgcIdHTTPRetry_Options として宣言されています。これを有効にすると、リクエストは自らリトライします。

uses
  sgcHTTP_Client;

var
  oHTTP: TsgcIdHTTP;
begin
  oHTTP := TsgcIdHTTP.Create(nil);
  try
    oHTTP.RetryOptions.Enabled         := True;    // default False
    oHTTP.RetryOptions.MaxRetries      := 3;
    oHTTP.RetryOptions.InitialInterval := 500;     // milliseconds
    oHTTP.RetryOptions.MaxInterval     := 30000;   // milliseconds
    oHTTP.RetryOptions.Multiplier      := 2.0;
    oHTTP.RetryOptions.Jitter          := 0.2;
    oHTTP.RetryOptions.StatusCodes     := '429,502,503,504';

    ShowMessage(oHTTP.Get('https://api.example.com/v1/orders'));
  finally
    oHTTP.Free;
  end;
end;

ここでの間隔は秒ではなくミリ秒単位です。HTTP のリトライは再接続よりもはるかに短い時間スケールで動くからです。遅延は WatchDog と同じカーブに従います。500ミリ秒、1秒、2秒、4秒と進み、MaxInterval で上限が掛かり、Jitter で分散されます。

StatusCodes は、リトライ対象とみなす HTTP ステータスコードのリストで、カンマ区切りの文字列です。既定値は 429,502,503,504 で、これらは「あなたのリクエストが間違っている」ではなく「今は無理なので後でもう一度」を意味するコードです。400 や 401 は決してリトライされません。同じ壊れたリクエストをあと4回送っても誰の役にも立たないからです。ソケットの拒否や読み取りタイムアウトのような接続レベルの失敗もリトライされます。

同じオブジェクトは sgcHTTP_APITsgcHTTPAPI_client にもあります。これはライブラリ内のすべての既製 REST クライアントの基底クラスです。したがって、リトライのポリシーはクライアントごとの配線を一切せずに、そのすべてで利用できます。

// Any API client that descends from TsgcHTTPAPI_client
sgcHTTPAPI_Client1.RetryOptions.Enabled     := True;
sgcHTTPAPI_Client1.RetryOptions.MaxRetries  := 5;
sgcHTTPAPI_Client1.RetryOptions.StatusCodes := '429,500,502,503,504';

Retry-After: ペースはサーバーに決めさせる

自前でバックオフを計算するのは当て推量です。サーバーが 429 や 503 を返すとき、多くの場合は Retry-After レスポンスヘッダーで、どれだけ待てばよいかを秒数または HTTP 日付として正確に教えてくれます。その数値は提案ではありません。いつなら再び受け付けるかをサーバーが伝えているのであり、それを無視して自分のスケジュールでリトライするクライアントは、単にまた拒否されるだけです。

HonorRetryAfter は既定で True です。レスポンスが Retry-After ヘッダーを伴う場合、その値が計算されたバックオフより優先され、クライアントはサーバーが求めた分だけ待ちます。

oHTTP.RetryOptions.Enabled         := True;
oHTTP.RetryOptions.HonorRetryAfter := True;   // default True
oHTTP.RetryOptions.InitialInterval := 500;
oHTTP.RetryOptions.MaxRetries      := 3;

// Server replies 429 with "Retry-After: 12".
// The computed backoff would have been 500ms, the client waits 12 seconds instead.

ヘッダーの両方の形式、つまり差分秒数の形式と HTTP 日付の形式の双方を解析します。これはまさに OpenAI と Anthropic が 429 を返すときにクライアントへ求めている振る舞いであり、レート制限から回復するクライアントと、レート制限されたままのクライアントを分ける違いです。

AI クライアントのリトライ

リトライがその真価を発揮するのは AI クライアントです。そこではレート制限が例外ではなく日常だからです。それぞれが自分の *Options プロパティに専用のリトライオプションオブジェクトを公開しているので、設定はモデルや API キーと並んでオブジェクトインスペクタに表示されます。

TsgcHTTP_API_OpenAI では、そのパスは OpenAIOptions.RetryOptions で、クラスは TsgcHTTPOpenAIRetry_Options です。

uses
  sgcHTTP_API_OpenAI;

begin
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Enabled         := True;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Retries         := 3;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Wait            := 10000;  // ms, first delay
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.MaxInterval     := 30000;  // ms, ceiling
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Multiplier      := 2.0;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Jitter          := 0.2;
  sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.HonorRetryAfter := True;
end;

Anthropic も AnthropicOptions.RetryOptions の下で同じ形をしており、Gemini は GeminiOptions.RetryOptions です。同じパターンは Grok、Mistral、DeepSeek、Ollama の各クライアント、そして Stripe と Paddle のクライアントにも存在します。決済呼び出しのリトライは、また別の意味でとても重要です。

// Anthropic returns 429 and 529 under load, both are treated as retryable.
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Enabled         := True;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Retries         := 3;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.HonorRetryAfter := True;

AI 系オブジェクトのプロパティ名が MaxRetriesInitialInterval ではなく RetriesWait になっている点に注意してください。これは、それらのコンポーネントですでに使われている命名に合わせるためです。挙動は同一で、内部では下層の HTTP クライアント上の同じ TsgcIdHTTPRetry_Options を設定しています。

失敗の理由を知る: Connect と LastError

リトライのループは、そのエラー処理の出来次第です。ある失敗がリトライに値するかどうかを判断する前に、その失敗が何だったのかを知る必要がありますが、これまで WebSocket クライアントでそれを知る唯一の方法は、OnError ハンドラーを配線し、メッセージをどこかのフィールドに退避しておくことでした。

2026.7 は、エラーを直接渡してくれる Connect のオーバーロードを sgcWebSocket_Client_Base に追加します。

function Connect(const aTimeout: Integer = 10000): Boolean; overload;
function Connect(out AError: string; const aTimeout: Integer = 10000): Boolean; overload;

これにより、手動の再接続ループが理由を調べて自分で判断できます。

var
  vError: string;
begin
  if not sgcWebSocketClient1.Connect(vError, 5000) then
  begin
    // A DNS failure or a refused socket is worth retrying.
    // A rejected certificate or a 401 on the handshake is not.
    Memo1.Lines.Add('connect failed: ' + vError);
    Exit;
  end;
end;

直前のエラーはコンポーネント自身にも保持されます。sgcWebSocket_ClassesTsgcWSComponent_Base は読み取り専用の LastError: string プロパティと ClearLastError メソッドを公開しているので、事後に理由を読み取ることも、計測したい試行の前にリセットすることもできます。

sgcWebSocketClient1.ClearLastError;
if not sgcWebSocketClient1.Connect(5000) then
  ShowMessage('connect failed: ' + sgcWebSocketClient1.LastError);

これは、これまでどおり発火し続ける OnError を置き換えるものではありません。単に、単純で同期的なコードが、単純な問いに答えるためにイベントハンドラーを用意する必要がなくなったというだけです。

提供状況

WatchDog のバックオフ、ジッター、HTTP のリトライ、そして新しい Connect のオーバーロードは、sgcWebSockets 2026.7 において Delphi 7 から 13 および C++Builder で、Win32/Win64、Linux64、macOS、Android、iOS にわたって利用できます。ここで説明したものはすべて既定では無効です。BackoffwdbFixedJitter は 0、RetryOptions.EnabledFalse なので、既存のアプリケーションは挙動の変化なしにアップグレードでき、意味のあるところでオプトインできます。

有効なサブスクリプションをお持ちのお客様は、カスタマーエリアから新しいビルドをダウンロードできます。トライアルのユーザーは esegece.com/products/websockets/download から更新されたインストーラーを入手できます。

ご質問やご意見、あるいは端末群に適したバックオフのプロファイル選びにお困りですか。お問い合わせください。コードを書いた本人が返信します。