Ponowne łączenie i ponawianie: wykładnicze wycofywanie, jitter i Retry-After | Blog eSeGeCe

Ponowne łączenie i ponawianie: wykładnicze wycofywanie, jitter i Retry-After

· Komponenty

Zrestartuj serwer WebSocket, do którego podłączonych jest dziesięć tysięcy klientów, a wydarzy się coś nieprzyjemnego. Każdy klient zauważy rozłączenie mniej więcej w tej samej chwili, każdy odczeka tę samą stałą liczbę sekund i każdy zadzwoni z powrotem w tym samym takcie. Serwer wstaje i natychmiast dostaje dziesięć tysięcy jednoczesnych uzgodnień TLS. Przewraca się albo zaczyna ograniczać ruch, a klienci ponawiają próbę równym krokiem. To jest właśnie stado nadciągających klientów, a stały interwał ponownego łączenia gwarantuje jego pojawienie się.

sgcWebSockets 2026.7 zajmuje się dwoma miejscami, w których klienci dobijają serwer będący już w tarapatach. WatchDog może teraz łączyć się ponownie z wykładniczym wycofywaniem i jitterem zamiast ze stałym interwałem, a klient HTTP potrafi sam ponowić nieudane żądanie, respektując podpowiedź Retry-After z serwera i korzystając z tej samej matematyki wycofywania. Oba mechanizmy są opcjonalne i domyślnie wyłączone, więc istniejące aplikacje zachowują się dokładnie tak jak wcześniej, dopóki ich nie włączysz.

Problem ze stałym interwałem ponownego łączenia

WatchDog od zawsze był standardowym sposobem na utrzymanie połączenia klienta. Ustawiasz Enabled, ustawiasz Interval w sekundach, a kiedy połączenie padnie, komponent ponawia próbę co Interval sekund, dopóki połączenie nie wróci.

To wystarcza dla jednego klienta na kapryśnym łączu Wi-Fi. To jest złe zachowanie dla całej floty. Przy stałym interwale każdy klient we flocie jest metronomem tykającym w tym samym rytmie, a restart serwera synchronizuje ich wszystkich: rozłączyli się w tej samej chwili, więc łączą się ponownie w tej samej chwili, i tak w nieskończoność. Co gorsza, interwał się nie dostosowuje. Jeśli serwer jest niedostępny przez dziesięć minut, klient z Interval = 1 wykonuje sześćset bezcelowych prób połączenia, i tak samo robi każdy inny.

Chcesz czegoś odwrotnego. Za pierwszym razem spróbuj szybko, bo większość rozłączeń jest przejściowa, a szybkie ponowne połączenie jest dla użytkownika niewidoczne. Potem się wycofuj, żeby serwer, który naprawdę leży, nie był okładany kolejnymi próbami. I rozprosz klientów, żeby nie przybyli wszyscy naraz.

Wykładnicze wycofywanie w WatchDog

Obiekt opcji WatchDog zyskał cztery właściwości. Typ znajduje się w sgcTCP_Classes:

type
  TsgcWatchDogBackoff = (wdbFixed, wdbExponential);

W każdym komponencie WebSocket opcje są dostępne przez Client.WatchDog, a ustawienia wycofywania wyglądają tak:

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;

Matematyka jest celowo nudna. Pierwsza próba ponownego połączenia czeka Interval. Każda kolejna próba mnoży poprzednie opóźnienie przez BackoffMultiplier, a wynik jest przycinany do MaxInterval. Przy powyższych wartościach opóźnienia wynoszą 1 s, 2 s, 4 s, 8 s, 16 s, 32 s, 60 s, 60 s, 60 s i tak dalej. Licznik zeruje się, gdy tylko klient znów jest połączony, więc krótkie zakłócenie sieci kosztuje Cię sekundę, a nie minutę.

Interval i MaxInterval są wyrażone w sekundach, tak jak zawsze było w WatchDog. MaxInterval równy 0 oznacza brak górnego limitu i jest wartością domyślną. Jeśli włączasz wykładnicze wycofywanie, ustaw limit, bo inaczej opóźnienie będzie się podwajać, aż zacznie być mierzone w godzinach.

Ważny szczegół dla obecnych użytkowników: Backoff domyślnie ma wartość wdbFixed. Jeśli zaktualizujesz bibliotekę i niczego nie zmienisz, Twój klient będzie łączył się ponownie z tym samym stałym interwałem, co zawsze. Obliczanie opóźnienia jest używane dopiero wtedy, gdy przełączysz się na wdbExponential lub ustawisz Jitter, i jest to ta sama funkcja pomocnicza, z której korzysta ponawianie HTTP, czyli sgcNextBackoffMs w sgcBase_Helpers.

Jitter i dlaczego ma znaczenie przy dużej skali

Samo wykładnicze wycofywanie nie rozbija stada. Sprawia jedynie, że stado przybywa rzadziej. Dziesięć tysięcy klientów, którzy wszyscy czekają 1 s, potem 2 s, potem 4 s, to nadal dziesięć tysięcy klientów przybywających razem, tyle że w coraz większych falach.

Jitter to coś, co naprawdę ich rozprasza. Jest ułamkiem obliczonego opóźnienia, stosowanym losowo dla każdego klienta i każdej próby. Przy Jitter = 0.2 nominalne opóźnienie 8 sekund staje się losową wartością w paśmie 20% wokół 8 sekund, więc klient A budzi się po 7,1 s, klient B po 8,6 s, a klient C po 8,0 s. Flota przybywa jako rozmyta smuga zamiast jako pik, a kolejka accept serwera nadąża.

// 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

Jitter jest niezależny od trybu wycofywania. Możesz zostawić Backoff na wdbFixed i ustawić tylko Jitter, co daje klasyczny stały interwał z rozsynchronizowanymi klientami. To często wystarcza samo w sobie i jest najmniejszą możliwą zmianą w istniejącej aplikacji.

Automatyczne ponawianie żądania HTTP

Druga połowa tej funkcji dotyczy strony HTTP. Pojedyncze żądanie HTTP, które kończy się kodem 503, bo serwer jest właśnie wdrażany na nowo, albo kodem 429, bo przekroczyłeś limit zapytań, tak naprawdę nie jest błędem. To żądanie, które należy wysłać ponownie za chwilę. Pisanie takiej pętli ręcznie jest nużące, a każdy pisze ją trochę źle.

TsgcIdHTTP ma teraz obiekt RetryOptions, zadeklarowany w sgcHTTP_Client jako TsgcIdHTTPRetry_Options. Włącz go, a żądanie samo się ponowi:

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;

Interwały są tu podane w milisekundach, a nie w sekundach, ponieważ ponawianie żądań HTTP dzieje się w znacznie krótszej skali czasowej niż ponowne łączenie. Opóźnienia układają się w tę samą krzywą co w WatchDog: 500 ms, 1 s, 2 s, 4 s, ograniczone przez MaxInterval i rozproszone przez Jitter.

StatusCodes to lista kodów statusu HTTP, które uznaje się za możliwe do ponowienia, zapisana jako łańcuch rozdzielony przecinkami. Domyślnie jest to 429,502,503,504, czyli kody oznaczające "nie teraz, spróbuj później", a nie "Twoje żądanie jest błędne". Kod 400 czy 401 nigdy nie jest ponawiany, bo wysłanie tego samego zepsutego żądania jeszcze cztery razy nikomu nie pomoże. Awarie na poziomie połączenia, takie jak odrzucone gniazdo albo przekroczony limit czasu odczytu, również są ponawiane.

Ten sam obiekt znajduje się w TsgcHTTPAPI_client w sgcHTTP_API, czyli w klasie bazowej każdego gotowego klienta REST w bibliotece. Zasady ponawiania są więc dostępne we wszystkich z nich, bez żadnego dodatkowego oprzyrządowania dla poszczególnych klientów:

// 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: niech serwer nadaje tempo

Obliczanie własnego wycofywania to zgadywanie. Kiedy serwer zwraca 429 albo 503, często mówi Ci dokładnie, jak długo czekać, w nagłówku odpowiedzi Retry-After, albo jako liczbę sekund, albo jako datę HTTP. Ta liczba nie jest sugestią. To serwer mówi Ci, kiedy znów Cię przyjmie, a klient, który ją ignoruje i ponawia próbę według własnego harmonogramu, po prostu zostanie znowu odrzucony.

HonorRetryAfter ma domyślnie wartość True. Kiedy odpowiedź niesie nagłówek Retry-After, jego wartość wygrywa z obliczonym wycofywaniem, a klient czeka tyle, ile poprosił serwer:

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.

Parsowane są obie formy nagłówka, forma z liczbą sekund i forma z datą HTTP. Dokładnie tego OpenAI i Anthropic oczekują od swoich klientów, gdy zwracają kod 429, i na tym polega różnica między klientem, który podnosi się po limicie zapytań, a klientem, który w tym limicie tkwi.

Ponawianie w klientach AI

To właśnie w klientach AI ponawianie zarabia na siebie, bo limity zapytań są tam rutyną, a nie wyjątkiem. Każdy z nich udostępnia własny obiekt opcji ponawiania we właściwości *Options, więc ustawienia są widoczne w Inspektorze obiektów obok modelu i klucza API.

W TsgcHTTP_API_OpenAI ścieżka to OpenAIOptions.RetryOptions, klasy 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 ma ten sam kształt pod AnthropicOptions.RetryOptions, a Gemini pod GeminiOptions.RetryOptions. Ten sam wzorzec istnieje w klientach Grok, Mistral, DeepSeek i Ollama oraz w klientach Stripe i Paddle, gdzie ponowione wywołanie płatności jest ważne w zupełnie inny sposób.

// 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;

Zwróć uwagę, że nazwy właściwości w obiektach AI to Retries i Wait, a nie MaxRetries i InitialInterval, aby zachować spójność z nazewnictwem już stosowanym w tych komponentach. Zachowanie jest identyczne, a wewnętrznie konfigurują one ten sam TsgcIdHTTPRetry_Options w kliencie HTTP pod spodem.

Wiedzieć, dlaczego się nie udało: Connect i LastError

Pętla ponawiania jest warta tyle, ile jej obsługa błędów. Zanim zdecydujesz, czy niepowodzenie warto ponowić, musisz wiedzieć, na czym polegało, a do tej pory jedynym sposobem, żeby się tego dowiedzieć w kliencie WebSocket, było podpięcie procedury obsługi OnError i schowanie komunikatu gdzieś w polu.

Wersja 2026.7 dodaje przeciążenie Connect, które podaje Ci błąd bezpośrednio, w sgcWebSocket_Client_Base:

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

Dzięki temu ręczna pętla ponownego łączenia może sprawdzić przyczynę i sama podjąć decyzję:

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;

Ostatni błąd jest też przechowywany w samym komponencie. TsgcWSComponent_Base w sgcWebSocket_Classes publikuje właściwość LastError: string tylko do odczytu oraz metodę ClearLastError, więc możesz odczytać przyczynę po fakcie i zresetować ją przed próbą, którą chcesz zmierzyć:

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

Nic z tego nie zastępuje OnError, które nadal działa tak jak zawsze. Oznacza to jedynie, że prosty, synchroniczny fragment kodu nie musi już konfigurować procedury obsługi zdarzenia, żeby odpowiedzieć na proste pytanie.

Dostępność

Wycofywanie i jitter w WatchDog, ponawianie żądań HTTP oraz nowe przeciążenie Connect są dostępne od sgcWebSockets 2026.7 dla Delphi od 7 do 13 oraz C++Builder, na Win32/Win64, Linux64, macOS, Android i iOS. Wszystko, co tu opisano, jest domyślnie wyłączone. Backoff ma wartość wdbFixed, Jitter wynosi 0, a RetryOptions.Enabled to False, więc istniejąca aplikacja aktualizuje się bez żadnej zmiany zachowania, a Ty włączasz te opcje tam, gdzie mają sens.

Klienci z aktywną subskrypcją mogą pobrać nową kompilację ze strefy klienta. Użytkownicy wersji próbnej mogą pobrać zaktualizowany instalator pod adresem esegece.com/products/websockets/download.

Pytania, uwagi albo pomoc w doborze profilu wycofywania dla Twojej floty? Skontaktuj się z nami, odpowiedzą Ci osoby, które napisały ten kod.