← Notas de desarrollo

Probar los tiempos de espera y reintentos limitados de WebClient con Reactor

En esta página

Coloca un timeout por intento antes de retryWhen y un timeout total después para incluir las demoras de reintento en el presupuesto global. Reintenta solo los fallos permitidos por el contrato de la API: este ejemplo GET reintenta HTTP 503 como máximo dos veces y se detiene ante tiempos de espera agotados u otros errores. Spring documenta los tiempos de espera del transporte por separado del tiempo de espera del publicador de Reactor.

¿Qué límite controla cada ajuste?

Ajuste Alcance en este ejemplo
CONNECT_TIMEOUT_MILLIS Establecimiento de una conexión TCP; no es un presupuesto completo para DNS, obtención del pool o TLS
responseTimeout Intervalo máximo entre lecturas de red al recibir una respuesta; no es un plazo para todo el cuerpo
Primer timeout(attemptLimit) Una suscripción hasta la decodificación del cuerpo; vuelve a empezar en cada reintento
Último timeout(totalLimit) La suscripción completa, con todos los intentos y demoras de reintento
StepVerifier.verify(GUARD) Espera máxima del ejecutor de pruebas; no configura el cliente

La documentación de responseTimeout de Reactor Netty describe un intervalo de lectura, por lo que una respuesta que sigue entregando datos puede superar esa duración en total. El tiempo de espera externo del publicador establece un plazo global independiente. La cancelación detiene esta suscripción; no deshace el trabajo ya realizado por el servicio remoto.

Limitar los reintentos antes de añadir más casos de fallo

Aplicar un tiempo de espera por intento, reintentar solo errores 503 admitidos y limitar toda la operación

El diagrama resume el anidamiento de operadores, no cada evento HTTP. Cada intento termina correctamente o emite un error. Solo un error 503 con reintentos disponibles provoca una nueva suscripción tras una demora; los demás errores detienen la operación de inmediato. El tiempo de espera externo puede cancelar tanto un intento activo como una demora de reintento.

Retry.fixedDelay(2, delay) permite dos reintentos después del intento inicial: como máximo tres intentos de aplicación por suscripción. .onRetryExhaustedThrow propaga la última excepción 503 en lugar de envolverla en una excepción de reintentos agotados. .disableRetry(true) desactiva el reintento automático independiente de Reactor Netty ante una interrupción TCP, para que no añada un reintento de transporte oculto a estos recuentos.

El ejemplo realiza un GET y reintenta deliberadamente solo 503. Un tiempo de espera agotado no demuestra que el servidor no haya hecho nada. No copies esta política a pagos u otras escrituras sin un contrato explícito de idempotencia. No procesa Retry-After, no reintenta 429, no aplica espera exponencial ni variación aleatoria, ni coordina reintentos entre clientes. La demora fija corta acelera las pruebas; elige los límites y las demoras de producción según el contrato de tu API y el presupuesto de carga.

Crear un proyecto independiente

Utiliza un directorio nuevo y vacío, JDK 17 y Gradle 8.4. El ejemplo fija Boot BOM 3.5.7 y MockWebServer 4.12.0 para coincidir con las pruebas del contrato de respuesta. Son parámetros de reproducción, no recomendaciones de versiones actuales para producción. La resolución inicial de dependencias utiliza Maven Central; las pruebas HTTP solo utilizan loopback. No se incluye una aplicación Boot en ejecución ni un wrapper de Gradle.

settings.gradle

rootProject.name = 'webclient-timeout-retry'

build.gradle

plugins { id 'java' }
repositories { mavenCentral() }
dependencies {
    implementation platform('org.springframework.boot:spring-boot-dependencies:3.5.7')
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test { useJUnitPlatform() }

Mantener los dos plazos en posiciones distintas

Guarda la siguiente implementación. El constructor recibe duraciones positivas elegidas por quien lo llama; la prueba proporciona valores explícitos. El tiempo de espera de respuesta y el de conexión TCP de 1000 ms pertenecen a la factoría de transporte.

src/main/java/RetryClient.java

import java.time.Duration;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.util.retry.Retry;

public class RetryClient {
    private final WebClient client;
    private final Duration attemptLimit;
    private final Duration totalLimit;
    private final Duration retryDelay;

    public RetryClient(WebClient client, Duration attemptLimit,
                       Duration totalLimit, Duration retryDelay) {
        this.client = client;
        this.attemptLimit = attemptLimit;
        this.totalLimit = totalLimit;
        this.retryDelay = retryDelay;
    }

    public static WebClient transport(String baseUrl, Duration responseLimit) {
        HttpClient http = HttpClient.create()
            .disableRetry(true)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
            .responseTimeout(responseLimit);
        return WebClient.builder().baseUrl(baseUrl)
            .clientConnector(new ReactorClientHttpConnector(http)).build();
    }

    public Mono<String> get() {
        return Mono.defer(() -> client.get().uri("/value")
                .retrieve().bodyToMono(String.class))
            .timeout(attemptLimit)
            .retryWhen(Retry.fixedDelay(2, retryDelay)
                .filter(error -> error instanceof WebClientResponseException response
                    && response.getStatusCode().value() == 503)
                .onRetryExhaustedThrow((spec, signal) -> signal.failure()))
            .timeout(totalLimit);
    }
}

Reproducir nueve contratos de la política

Siete pruebas utilizan un WebClient real y un MockWebServer local. Dos utilizan un intercambio sintético y el tiempo virtual de Reactor para verificar la temporización de los operadores sin depender de la planificación de la red. El tiempo virtual no prueba los tiempos de espera reales de los sockets de Reactor Netty. La prueba de transporte comprueba la causa anidada del tiempo de lectura agotado, mientras que el tiempo de espera del publicador produce TimeoutException.

src/test/java/RetryClientTest.java

import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import io.netty.handler.timeout.ReadTimeoutException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.SocketPolicy;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.test.StepVerifier;
import static org.junit.jupiter.api.Assertions.*;

class RetryClientTest {
    private MockWebServer server;
    private WebClient client;
    private static final Duration GUARD = Duration.ofSeconds(10);

    @BeforeAll
    static void warmUp() {
        HttpClient.create().warmup().block(GUARD);
    }

    @BeforeEach
    void start() throws IOException {
        server = new MockWebServer();
        server.start();
        client = RetryClient.transport(server.url("/").toString(), Duration.ofSeconds(5));
    }

    @AfterEach
    void stop() throws IOException {
        server.shutdown();
    }

    RetryClient policy() {
        return new RetryClient(client, Duration.ofSeconds(3),
            Duration.ofSeconds(8), Duration.ofMillis(10));
    }

    void expectStatus(int status) {
        StepVerifier.create(policy().get())
            .expectErrorSatisfies(error -> assertEquals(status,
                assertInstanceOf(WebClientResponseException.class, error).getStatusCode().value()))
            .verify(GUARD);
    }

    @Test
    void successUsesOneRequest() {
        server.enqueue(new MockResponse().setBody("ok"));
        StepVerifier.create(policy().get()).expectNext("ok").expectComplete().verify(GUARD);
        assertEquals(1, server.getRequestCount());
    }

    @Test
    void two503ResponsesThenSuccessUseThreeRequests() {
        server.enqueue(new MockResponse().setResponseCode(503));
        server.enqueue(new MockResponse().setResponseCode(503));
        server.enqueue(new MockResponse().setBody("ok"));
        StepVerifier.create(policy().get()).expectNext("ok").expectComplete().verify(GUARD);
        assertEquals(3, server.getRequestCount());
    }

    @Test
    void exhausted503PreservesLastStatusAfterThreeRequests() {
        for (int i = 0; i < 3; i++) server.enqueue(new MockResponse().setResponseCode(503));
        expectStatus(503);
        assertEquals(3, server.getRequestCount());
    }

    @Test
    void notFoundIsNotRetried() {
        server.enqueue(new MockResponse().setResponseCode(404));
        expectStatus(404);
        assertEquals(1, server.getRequestCount());
    }

    @Test
    void otherServerErrorsAreNotRetried() {
        server.enqueue(new MockResponse().setResponseCode(500));
        expectStatus(500);
        assertEquals(1, server.getRequestCount());
    }

    @Test
    void attemptTimeoutIsNotRetried() {
        server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
        RetryClient limited = new RetryClient(client, Duration.ofSeconds(1),
            Duration.ofSeconds(8), Duration.ofMillis(10));
        StepVerifier.create(limited.get()).expectError(TimeoutException.class).verify(GUARD);
        assertEquals(1, server.getRequestCount());
    }

    @Test
    void responseReadTimeoutIsNotRetried() {
        server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE));
        WebClient shortRead = RetryClient.transport(server.url("/").toString(), Duration.ofMillis(250));
        RetryClient limited = new RetryClient(shortRead, Duration.ofSeconds(3),
            Duration.ofSeconds(8), Duration.ofMillis(10));
        StepVerifier.create(limited.get()).expectErrorSatisfies(error -> {
            assertInstanceOf(WebClientRequestException.class, error);
            assertInstanceOf(ReadTimeoutException.class, error.getCause());
        }).verify(GUARD);
        assertEquals(1, server.getRequestCount());
    }

    @Test
    void totalBudgetIncludesRetryDelay() {
        AtomicInteger attempts = new AtomicInteger();
        StepVerifier.withVirtualTime(() -> {
            WebClient synthetic = WebClient.builder().exchangeFunction(request -> Mono.defer(() -> {
                attempts.incrementAndGet();
                return Mono.just(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE).build());
            })).build();
            return new RetryClient(synthetic, Duration.ofSeconds(1),
                Duration.ofMillis(1500), Duration.ofSeconds(1)).get();
        }).thenAwait(Duration.ofMillis(1500)).expectError(TimeoutException.class).verify(GUARD);
        assertEquals(2, attempts.get());
    }

    @Test
    void attemptBudgetRestartsAfterRetry() {
        AtomicInteger attempts = new AtomicInteger();
        StepVerifier.withVirtualTime(() -> {
            WebClient synthetic = WebClient.builder().exchangeFunction(request -> Mono.defer(() -> {
                int attempt = attempts.incrementAndGet();
                return Mono.delay(Duration.ofMillis(400)).map(tick -> attempt == 1
                    ? ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE).build()
                    : ClientResponse.create(HttpStatus.OK).body("ok").build());
            })).build();
            return new RetryClient(synthetic, Duration.ofMillis(500),
                Duration.ofSeconds(2), Duration.ofMillis(200)).get();
        }).thenAwait(Duration.ofSeconds(1)).expectNext("ok").expectComplete().verify(GUARD);
        assertEquals(2, attempts.get());
    }
}

Ejecutar el proyecto e interpretar el resultado

gradle --no-daemon clean test

La reproducción local utilizó JDK 17.0.8.1 y Gradle 8.4. Las nueve pruebas pasaron. Consulta build/reports/tests/test/index.html o build/test-results/test/TEST-RetryClientTest.xml para ver las aserciones; BUILD SUCCESSFUL por sí solo no indica cuántas pruebas se ejecutaron.

Escenario Resultado comprobado
Éxito inmediato Una solicitud HTTP, ok y finalización
503 → 503 → éxito Tres solicitudes HTTP, ok y finalización
503 en todos los intentos Tres solicitudes HTTP, se conserva el último 503
404 / 500 Una solicitud HTTP para cada uno, se conserva el estado
Sin respuesta, límite por intento de un segundo TimeoutException, una solicitud HTTP
Sin respuesta, límite de lectura de respuesta de 250 ms WebClientRequestException con ReadTimeoutException, una solicitud
Presupuesto total de 1.5 s, demora de reintento de un segundo Dos intentos sintéticos; el tiempo de espera cancela la demora antes del tercero
Intento de 400 ms, demora de 200 ms, intento de 400 ms Cada intento cabe en 500 ms; se comprueban el éxito y la finalización tras avanzar el tiempo virtual 1 s

Estas comprobaciones no reproducen el tiempo de espera al establecer TCP, fallos DNS, negociación TLS, agotamiento del pool, redirecciones ni efectos secundarios distribuidos. El límite de conexión está configurado, pero aquí no se valida experimentalmente. La planificación del SO puede afectar al tiempo real transcurrido; las pruebas verifican tipos de error y recuentos de solicitudes, no milisegundos exactos de reloj. Para el estado, el tipo de contenido y JSON, continúa con el ejemplo del contrato de respuesta de WebClient.

Comentarios

Ver en GitHub ↗