Reconexión y reintentos: retroceso exponencial, jitter y Retry-After | Blog de eSeGeCe

Reconexión y reintentos: retroceso exponencial, jitter y Retry-After

· Componentes

Reinicia un servidor WebSocket que tiene diez mil clientes conectados y ocurre algo desagradable. Todos los clientes se dan cuenta de la desconexión aproximadamente en el mismo instante, todos esperan el mismo número fijo de segundos, y todos vuelven a marcar en el mismo tic. El servidor arranca e inmediatamente recibe diez mil negociaciones TLS simultáneas. Se cae, o limita el tráfico, y los clientes vuelven a intentarlo al unísono. Esto es la estampida, y un intervalo de reconexión fijo la garantiza.

sgcWebSockets 2026.7 aborda los dos puntos donde los clientes machacan a un servidor que ya está en apuros. El WatchDog puede ahora reconectar con retroceso exponencial más jitter en lugar de un intervalo fijo, y el cliente HTTP puede reintentar por sí solo una petición fallida, respetando la pista Retry-After del servidor, usando las mismas matemáticas de retroceso. Ambas cosas son opcionales y están desactivadas por defecto, así que las aplicaciones existentes se comportan exactamente igual que antes hasta que las habilitas.

El problema de un intervalo de reconexión fijo

El WatchDog siempre ha sido la forma estándar de mantener conectado a un cliente. Estableces Enabled, estableces un Interval en segundos, y cuando la conexión se cae el componente reintenta cada Interval segundos hasta que vuelve.

Eso está bien para un cliente en un enlace Wi-Fi inestable. Es el comportamiento equivocado para una flota. Con un intervalo fijo, cada cliente de la flota es un metrónomo que marca al mismo ritmo, y un reinicio del servidor los sincroniza a todos: todos se desconectaron en el mismo instante, así que todos reconectan en el mismo instante, para siempre. Peor aún, el intervalo no se adapta. Si el servidor está caído durante diez minutos, un cliente con Interval = 1 realiza seiscientos intentos de conexión inútiles, y todos los demás también.

Lo que quieres es lo contrario. Reintentar rápido la primera vez, porque la mayoría de las desconexiones son transitorias y una reconexión rápida es invisible para el usuario. Después retroceder, para que un servidor que está genuinamente caído no reciba una paliza. Y repartir a los clientes, para que no lleguen todos juntos.

Retroceso exponencial en el WatchDog

El objeto de opciones del WatchDog ha ganado cuatro propiedades. El tipo vive en sgcTCP_Classes:

type
  TsgcWatchDogBackoff = (wdbFixed, wdbExponential);

En cualquier componente WebSocket se accede a las opciones como Client.WatchDog, y los ajustes de retroceso son estos:

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;

Las matemáticas son deliberadamente aburridas. El primer intento de reconexión espera Interval. Cada intento posterior multiplica el retardo anterior por BackoffMultiplier, y el resultado se limita a MaxInterval. Con los valores de arriba los retardos van 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s y así sucesivamente. El contador se reinicia en cuanto el cliente vuelve a estar conectado, así que un breve corte de red te cuesta un segundo, no un minuto.

Interval y MaxInterval se expresan ambos en segundos, como siempre ha sido en el WatchDog. Un MaxInterval de 0 significa que no hay techo, que es el valor por defecto. Si habilitas el retroceso exponencial, pon un techo, o el retardo seguirá duplicándose hasta medirse en horas.

El detalle importante para los usuarios existentes: Backoff vale wdbFixed por defecto. Si actualizas y no cambias nada, tu cliente reconecta con el mismo intervalo fijo de siempre. El cálculo del retardo sólo se usa cuando cambias a wdbExponential o estableces un Jitter, y es el mismo ayudante que usa el reintento HTTP, sgcNextBackoffMs en sgcBase_Helpers.

El jitter, y por qué importa a escala

El retroceso exponencial por sí solo no rompe la estampida. Sólo hace que la manada llegue con menos frecuencia. Diez mil clientes que esperan todos 1s, luego 2s, luego 4s siguen siendo diez mil clientes llegando juntos, sólo que en oleadas cada vez más grandes.

Jitter es lo que realmente los reparte. Es una fracción del retardo calculado, aplicada aleatoriamente por cliente y por intento. Con Jitter = 0.2, un retardo nominal de 8 segundos se convierte en un valor aleatorio dentro de una banda del 20% alrededor de los 8 segundos, así que el cliente A despierta a los 7,1s, el cliente B a los 8,6s, el cliente C a los 8,0s. La flota llega como una mancha difusa en lugar de como un pico, y la cola de aceptación del servidor puede seguir el ritmo.

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

El jitter es ortogonal al modo de retroceso. Puedes dejar Backoff en wdbFixed y establecer sólo Jitter, lo que te da el clásico intervalo fijo con los clientes desincronizados. Eso a menudo basta por sí solo, y es el cambio más pequeño posible en una aplicación existente.

Reintentar una petición HTTP automáticamente

La segunda mitad de la funcionalidad está en el lado HTTP. Una única petición HTTP que falla con un 503 porque el servidor se está redesplegando, o con un 429 porque te has pasado de un límite de tasa, no es realmente un error. Es una petición que debería enviarse de nuevo en un momento. Escribir ese bucle a mano es tedioso y todo el mundo lo escribe ligeramente mal.

TsgcIdHTTP tiene ahora un objeto RetryOptions, declarado en sgcHTTP_Client como TsgcIdHTTPRetry_Options. Habilítalo y la petición se reintenta a sí misma:

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;

Aquí los intervalos van en milisegundos, no en segundos, porque los reintentos HTTP viven en una escala de tiempo mucho más corta que una reconexión. Los retardos siguen la misma curva que el WatchDog: 500ms, 1s, 2s, 4s, limitados por MaxInterval y repartidos por Jitter.

StatusCodes es la lista de códigos de estado HTTP que cuentan como reintentables, en forma de cadena separada por comas. El valor por defecto es 429,502,503,504, que son los códigos que significan "ahora no, inténtalo de nuevo" en lugar de "tu petición está mal". Un 400 o un 401 no se reintenta nunca, porque enviar cuatro veces más la misma petición defectuosa no ayuda a nadie. Los fallos a nivel de conexión, como un socket rechazado o un tiempo de espera de lectura agotado, también se reintentan.

El mismo objeto está en TsgcHTTPAPI_client, en sgcHTTP_API, que es la clase base de todos los clientes REST ya preparados de la librería. Así que la política de reintentos está disponible en todos ellos sin ninguna fontanería por cliente:

// 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: deja que el servidor marque el ritmo

Calcular tu propio retroceso es una suposición. Cuando un servidor devuelve un 429 o un 503, a menudo te dice exactamente cuánto tienes que esperar, en la cabecera de respuesta Retry-After, ya sea como un número de segundos o como una fecha HTTP. Ese número no es una sugerencia. Es el servidor diciéndote cuándo volverá a aceptarte, y un cliente que lo ignora y reintenta según su propio calendario simplemente va a ser rechazado de nuevo.

HonorRetryAfter vale True por defecto. Cuando la respuesta lleva una cabecera Retry-After, ese valor gana sobre el retroceso calculado, y el cliente espera todo lo que el servidor haya pedido:

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.

Se interpretan ambas formas de la cabecera, la forma de segundos de diferencia y la forma de fecha HTTP. Esto es exactamente lo que OpenAI y Anthropic piden a sus clientes que hagan cuando devuelven un 429, y es la diferencia entre un cliente que se recupera de un límite de tasa y un cliente que se queda limitado.

Reintentos en los clientes de IA

Los clientes de IA son donde los reintentos se ganan el sueldo, porque allí los límites de tasa son rutina y no una excepción. Cada uno de ellos expone su propio objeto de opciones de reintento en su propiedad *Options, así que los ajustes son visibles en el Inspector de Objetos junto al modelo y la clave de API.

En TsgcHTTP_API_OpenAI la ruta es OpenAIOptions.RetryOptions, de la clase 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 tiene la misma forma bajo AnthropicOptions.RetryOptions, y Gemini bajo GeminiOptions.RetryOptions. El mismo patrón existe en los clientes de Grok, Mistral, DeepSeek y Ollama, y en los clientes de Stripe y Paddle, donde una llamada de pago reintentada es un tipo de importancia muy distinto.

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

Fíjate en que los nombres de las propiedades en los objetos de IA son Retries y Wait en lugar de MaxRetries e InitialInterval, para mantener la coherencia con la nomenclatura que ya se usa en otras partes de esos componentes. El comportamiento es idéntico, e internamente configuran el mismo TsgcIdHTTPRetry_Options en el cliente HTTP subyacente.

Saber por qué falló: Connect y LastError

Un bucle de reintentos es tan bueno como su manejo de errores. Antes de poder decidir si merece la pena reintentar tras un fallo, tienes que saber cuál fue el fallo, y hasta ahora la única forma de averiguarlo en un cliente WebSocket era conectar un manejador OnError y guardar el mensaje en algún campo.

2026.7 añade una sobrecarga de Connect que te entrega el error directamente, en sgcWebSocket_Client_Base:

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

Así que un bucle de reconexión manual puede inspeccionar el motivo y decidir por sí mismo:

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;

El último error también se guarda en el propio componente. TsgcWSComponent_Base, en sgcWebSocket_Classes, publica una propiedad de sólo lectura LastError: string y un método ClearLastError, así que puedes leer el motivo a posteriori, y reiniciarlo antes de un intento que quieras medir:

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

Nada de esto sustituye a OnError, que sigue disparándose como siempre. Simplemente significa que un trozo de código sencillo y síncrono ya no tiene que montar un manejador de eventos para responder a una pregunta sencilla.

Disponibilidad

El retroceso del WatchDog, el jitter, los reintentos HTTP y la nueva sobrecarga de Connect están disponibles desde sgcWebSockets 2026.7 en Delphi 7 hasta 13 y C++Builder, en Win32/Win64, Linux64, macOS, Android e iOS. Todo lo descrito aquí está desactivado por defecto. Backoff vale wdbFixed, Jitter vale 0 y RetryOptions.Enabled vale False, así que una aplicación existente se actualiza sin ningún cambio de comportamiento en absoluto y tú lo activas donde tenga sentido.

Los clientes con una suscripción activa pueden descargar la nueva compilación desde el área de clientes. Los usuarios de la versión de prueba pueden obtener el instalador actualizado en esegece.com/products/websockets/download.

¿Preguntas, comentarios o ayuda para elegir un perfil de retroceso para tu flota? Ponte en contacto, recibirás respuesta de las personas que escribieron el código.