La lección anterior dejó una promesa sin cumplir: dijimos que un servicio llama a otro, que hay que propagar el JWT y el identificador de traza, que hace falta un cortacircuitos y que la caída de un servicio no debe encadenarse. Nada de eso lo hemos escrito todavía. Y hace falta aunque CicloUrbana no se divida nunca: en cuanto la aplicación llama a la pasarela de pagos de Ribalta —un sistema externo, con su latencia, sus caídas y sus errores 500— aparecen exactamente los mismos problemas.
Esta lección los resuelve con código. Primero el cliente: qué opciones ofrece Spring, cómo se configura RestClient para hablar con la pasarela, por qué los tiempos de espera son el ajuste más caro de olvidar, cómo se propagan la traza y el token, y cómo se traduce un error remoto a una excepción del dominio. Después la resiliencia: los cinco patrones de Resilience4j uno a uno, en qué orden se aplican, y la decisión —de negocio, no técnica— de qué hace CicloUrbana cuando la pasarela no responde. Y al final, cómo se prueba todo esto simulando un servicio lento, caído y roto.
Contenido
- Los clientes HTTP de Spring
- El cliente de la pasarela de pagos
- Tiempos de espera: el ajuste que no se puede olvidar
- Interceptores: propagar la traza y el token
- Traducir los errores remotos al dominio
- La interfaz declarativa con
@HttpExchange - Resilience4j: instalación y configuración
- Reintentos
- Cortacircuitos
- Limitador de tasa, mamparo y tiempo límite
- El orden de los decoradores
- Degradación elegante
- Observabilidad de la resiliencia
- Probar los fallos con WireMock
- Comunicación asíncrona: el mínimo imprescindible
- Errores Comunes y Consejos
- Ejercicios
- Los clientes HTTP de Spring
| Cliente | Estado | Modelo | Cuándo usarlo |
|---|---|---|---|
RestTemplate |
En mantenimiento desde Spring 5 | Bloqueante | Solo código heredado; no empezar nada nuevo con él |
RestClient |
Spring Framework 6.1+ | Bloqueante, API fluida | La opción recomendada en aplicaciones síncronas como CicloUrbana |
WebClient |
Estable | Reactivo (Mono/Flux) |
WebFlux, o llamadas concurrentes con composición |
@HttpExchange + HttpServiceProxyFactory |
Spring 6+ | Interfaz declarativa | Clientes con varios métodos: la preferida en este curso |
| OpenFeign | Spring Cloud | Interfaz declarativa | Proyectos que ya lo usan; el estándar de Spring lo sustituye |
Dos aclaraciones útiles. RestTemplate no está obsoleto ni va a desaparecer, pero no recibe funcionalidad nueva; migrar a RestClient es casi mecánico porque comparte la infraestructura (ClientHttpRequestFactory, interceptores, conversores). Y usar WebClient en una aplicación servlet solo para llamar a un servicio y hacer .block() es un antipatrón común: arrastra toda la pila reactiva para obtener el comportamiento de RestClient con más complejidad. La elección para CicloUrbana: RestClient como base y @HttpExchange para exponerlo como interfaz de dominio.
- El cliente de la pasarela de pagos
Partimos de la PasarelaProperties de 02-05, ampliada con los tiempos de espera:
@ConfigurationProperties(prefix = "ciclourbana.pasarela")
@Validated
public record PasarelaProperties(
@NotBlank String url,
@NotBlank String apiKey,
@NotNull @DurationMin(millis = 200) @DurationMax(seconds = 10) Duration esperaConexion,
@NotNull @DurationMin(millis = 200) @DurationMax(seconds = 30) Duration esperaLectura) {
}@Bean
RestClient clientePasarela(PasarelaProperties propiedades, InterceptorTraza interceptorTraza) {
ClientHttpRequestFactorySettings ajustes = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(propiedades.esperaConexion()) // 2s
.withReadTimeout(propiedades.esperaLectura()); // 5s
return RestClient.builder()
.baseUrl(propiedades.url()) // https://pagos.ribalta.example/api/v1
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + propiedades.apiKey())
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.USER_AGENT, "CicloUrbana/2.4.0")
.requestFactory(ClientHttpRequestFactories.get(ajustes))
.requestInterceptor(interceptorTraza)
.defaultStatusHandler(HttpStatusCode::isError, this::traducirError)
.build();
}Cuatro decisiones. Es un bean, no un RestClient creado en cada llamada: crearlo por invocación descarta el pool de conexiones y añade una negociación TLS completa a cada petición. La clave de la API va como cabecera por defecto, así que ningún método puede olvidarla —y nunca en la URL, donde acabaría en los logs de acceso y en los proxies intermedios—. El User-Agent identifica a CicloUrbana en los logs de la pasarela, lo que ahorra discusiones cuando hay que investigar un incidente con el proveedor. Y hay un cliente por destino: si mañana aparece un servicio de mapas, tendrá su propio bean con sus propios tiempos y su propio cortacircuitos.
- Tiempos de espera: el ajuste que no se puede olvidar
No configurar los tiempos de espera es el error más caro de toda la lección, y es fácil cometerlo porque no produce ningún síntoma hasta el día en que el sistema remoto se degrada.
Sin ellos, una petición a una pasarela que acepta la conexión y no responde nunca espera indefinidamente, y el hilo que atiende al ciudadano queda bloqueado. Con doscientos ciudadanos intentando pagar, los doscientos hilos de Tomcat quedan retenidos, y a partir de ahí CicloUrbana entera deja de responder: ni consultar estaciones, ni iniciar alquileres, ni siquiera /actuator/health si comparte puerto. Un problema de un tercero se ha convertido en una caída total del servicio municipal.
| Tiempo | Qué mide | Valor razonable | Si no se configura |
|---|---|---|---|
| Conexión | Establecer el socket TCP | 1-3 s | Espera del sistema operativo: minutos |
| Lectura | Entre bytes de la respuesta | 3-10 s | Infinito |
| Espera de conexión del pool | Obtener una conexión libre | 1-2 s | Puede bloquear indefinidamente |
| Global de la llamada | Toda la operación con reintentos | Suma acotada | No existe: hay que imponerlo |
La regla para fijarlos: parte del presupuesto de latencia de tu propio endpoint. Si POST /api/v1/alquileres/{id}/finalizar debe responder en menos de un segundo, no puede permitirse una espera de lectura de treinta; es más útil fallar rápido y degradar (apartado 12) que esperar a una respuesta que ya no le sirve a nadie. Y una advertencia sobre los reintentos: la espera efectiva se multiplica —con una lectura de 5 s y tres intentos, el peor caso son 15 s más las pausas—, que es por lo que el apartado 11 insiste en el orden de los decoradores y en un límite global.
- Interceptores: propagar la traza y el token
Un interceptor se ejecuta antes de cada petición saliente y es el sitio natural para las cabeceras transversales:
package com.ciclourbana.comun;
@Component
public class InterceptorTraza implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest peticion, byte[] cuerpo,
ClientHttpRequestExecution ejecucion) throws IOException {
String traza = MDC.get(FiltroTraza.CLAVE_MDC);
if (traza != null) {
peticion.getHeaders().add("X-Traza-Id", traza);
}
long inicio = System.nanoTime();
ClientHttpResponse respuesta = ejecucion.execute(peticion, cuerpo);
log.debug("{} {} -> {} en {} ms", peticion.getMethod(), peticion.getURI(),
respuesta.getStatusCode(),
Duration.ofNanos(System.nanoTime() - inicio).toMillis());
return respuesta;
}
}Con esta cabecera, el identificador de traza del FiltroTraza de 03-06 viaja al sistema remoto y aparece en sus logs: cuando el proveedor de la pasarela pregunte por una transacción concreta, la referencia es la misma en ambos lados. En 09-06, Micrometer Tracing hará esto de forma estándar con las cabeceras traceparent del W3C. Para propagar el JWT hacia otro servicio de CicloUrbana —el token relay de 07-05— el interceptor lee el contexto de seguridad:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getCredentials() instanceof String token) {
peticion.getHeaders().setBearerAuth(token);
}
return ejecucion.execute(peticion, cuerpo);Nunca hacia un tercero: este interceptor solo debe registrarse en clientes que apuntan a servicios propios; enviar el token de un ciudadano de Ribalta a la pasarela de pagos externa sería una filtración de credenciales, y por eso la pasarela usa su propia apiKey. Y el SecurityContextHolder es un ThreadLocal: si la llamada sale de un hilo asíncrono (07-03), aquí no habrá nada salvo que el ejecutor esté envuelto en DelegatingSecurityContextAsyncTaskExecutor.
- Traducir los errores remotos al dominio
Sin tratamiento, un 402 Payment Required de la pasarela se convierte en una HttpClientErrorException que sube hasta el ManejadorGlobalExcepciones de 03-06, que la desconoce y devuelve un 500 genérico. Al ciudadano le llega «error interno» cuando lo que pasa es que su tarjeta no tiene saldo.
private void traducirError(HttpRequest peticion, ClientHttpResponse respuesta) throws IOException {
HttpStatusCode estado = respuesta.getStatusCode();
String cuerpo = new String(respuesta.getBody().readAllBytes(), StandardCharsets.UTF_8);
log.warn("La pasarela respondió {} a {}", estado, peticion.getURI());
if (estado.value() == 402) throw new PagoRechazadoException(extraerMotivo(cuerpo));
if (estado.is4xxClientError()) throw new PasarelaRechazoException("Petición rechazada: " + estado);
throw new PasarelaNoDisponibleException("La pasarela devolvió " + estado);
}Y en el manejador global, tres reglas nuevas coherentes con el ProblemDetail de 03-06:
| Excepción propia | Estado hacia el ciudadano | Motivo |
|---|---|---|
PagoRechazadoException |
402 Payment Required |
Es un problema del ciudadano y puede resolverlo |
PasarelaRechazoException |
500 |
Un 4xx de la pasarela es un fallo nuestro: petición mal formada |
PasarelaNoDisponibleException |
503 Service Unavailable |
Es temporal; se añade Retry-After |
Dos principios detrás de esa tabla. La excepción de transporte no debe escapar: que el cliente use RestClient, Feign o un socket a pelo es un detalle de implementación, y HttpClientErrorException en la firma de un servicio de dominio acopla la aplicación a la biblioteca. Y el código de estado hacia el ciudadano no es el del remoto: que la pasarela devuelva 400 porque enviamos un campo mal no significa que la petición del ciudadano fuera incorrecta.
Cuidado, además, con lo que se registra: el cuerpo de la respuesta de una pasarela de pagos puede contener datos de tarjeta, así que nunca debe volcarse entero al log ni, mucho menos, al mensaje de la excepción que llega al cliente.
- La interfaz declarativa con
@HttpExchange
@HttpExchangeCon más de dos o tres operaciones, el RestClient desnudo dispersa URIs y tipos por el código. La interfaz declarativa los reúne en un contrato legible:
package com.ciclourbana.pagos;
@HttpExchange(url = "/pagos", accept = "application/json", contentType = "application/json")
public interface ClientePasarelaPagos {
@PostExchange
RespuestaCobro cobrar(@RequestBody SolicitudCobro solicitud);
@GetExchange("/{referencia}")
RespuestaCobro consultar(@PathVariable String referencia);
@PostExchange("/{referencia}/devoluciones")
RespuestaDevolucion devolver(@PathVariable String ref, @RequestBody SolicitudDevolucion s);
}@Bean
ClientePasarelaPagos clientePasarelaPagos(RestClient clientePasarela) {
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(clientePasarela))
.build()
.createClient(ClientePasarelaPagos.class);
}Spring genera la implementación, que usa por debajo el RestClient del apartado 2 con sus tiempos de espera, sus interceptores y su traductor de errores. Las ventajas frente al cliente desnudo: la interfaz es el contrato y se lee de un vistazo, el servicio de dominio depende de una abstracción propia y no de una biblioteca HTTP, y en las pruebas se sustituye por un mock de Mockito (06-03) sin levantar nada.
@HttpExchange es el equivalente estándar de OpenFeign sin depender de Spring Cloud, con una correspondencia casi uno a uno (@FeignClient → @HttpExchange, @GetMapping → @GetExchange).
- Resilience4j: instalación y configuración
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>spring-boot-starter-aop no es opcional: las anotaciones de Resilience4j funcionan con proxies, con las mismas trampas de autoinvocación de @Transactional (04-07) y @Async (07-03) —un método anotado que se llama con this. no está protegido por nada—. Toda la configuración es YAML, con configs.default heredable más ajustes por instancia:
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowType: COUNT_BASED
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50 # % de fallos que abre el circuito
slowCallRateThreshold: 80 # % de llamadas lentas que también lo abre
slowCallDurationThreshold: 3s
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
registerHealthIndicator: true
recordExceptions: [com.ciclourbana.pagos.PasarelaNoDisponibleException, java.io.IOException]
ignoreExceptions: [com.ciclourbana.pagos.PagoRechazadoException]
instances:
pasarelaPagos: { baseConfig: default }
retry:
instances:
pasarelaPagos:
maxAttempts: 3
waitDuration: 500ms
exponentialBackoffMultiplier: 2
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions: [com.ciclourbana.pagos.PasarelaNoDisponibleException, java.io.IOException]
ignoreExceptions: [com.ciclourbana.pagos.PagoRechazadoException]
ratelimiter:
instances:
pasarelaPagos: { limitForPeriod: 50, limitRefreshPeriod: 1s, timeoutDuration: 200ms }
bulkhead:
instances:
pasarelaPagos: { maxConcurrentCalls: 10, maxWaitDuration: 100ms }
timelimiter:
instances:
pasarelaPagos: { timeoutDuration: 8s, cancelRunningFuture: true }ignoreExceptions con PagoRechazadoException es la línea más importante de todo el bloque. Una tarjeta sin saldo es una respuesta correcta de la pasarela: no debe reintentarse ni contar como fallo para abrir el cortacircuitos. Si se contara, mil ciudadanos con la tarjeta caducada bastarían para dejar sin cobros a toda la red de Ribalta.
- Reintentos
Un reintento resuelve fallos transitorios: una pérdida de paquetes, un 503 durante un despliegue del proveedor, un pico momentáneo.
@Retry(name = "pasarelaPagos")
public RespuestaCobro cobrar(Long alquilerId, BigDecimal importe) {
return cliente.cobrar(new SolicitudCobro(alquilerId, importe, claveIdempotencia(alquilerId)));
}Dos conceptos que hay que entender.
Retroceso exponencial con jitter. Reintentar cada 500 ms de forma fija tiene un efecto perverso: si mil peticiones fallan a la vez porque la pasarela se reinició, las mil reintentan a la vez y la tumban de nuevo —la tormenta de reintentos—. El retroceso exponencial separa los intentos (500 ms, 1 s, 2 s) y el jitter (enableRandomizedWait) les añade variación aleatoria: con randomizedWaitFactor: 0.5, la segunda espera cae entre 250 y 750 ms.
Idempotencia: qué es seguro reintentar. Esta es la regla que decide si el reintento ayuda o duplica cobros, y enlaza con la semántica de los métodos HTTP de 03-03:
| Operación | ¿Reintentar? | Motivo |
|---|---|---|
GET, PUT, DELETE |
Sí | Consultas sin efectos u operaciones idempotentes por definición |
POST /pagos con clave de idempotencia |
Sí | El servidor descarta el duplicado |
POST /pagos sin clave |
No | El fallo pudo ocurrir después de cobrar: se cobraría dos veces |
El caso peligroso es un tiempo de espera agotado: no se sabe si la operación se ejecutó o no —la respuesta se perdió, pero el cobro pudo aplicarse—. La solución es la clave de idempotencia: CicloUrbana envía en cada cobro una clave derivada del alquiler (cobro-alquiler-42) y la pasarela, si ya la vio, devuelve el resultado original sin volver a cobrar. Sin ese mecanismo del lado servidor, reintentar un POST de cobro es inaceptable.
- Cortacircuitos
El reintento sirve para fallos pasajeros. Cuando el fallo es sostenido, reintentar empeora las cosas: consume hilos, alarga las respuestas y machaca a un sistema que ya está caído. El cortacircuitos corta esa realimentación.
stateDiagram-v2
[*] --> CERRADO
CERRADO --> ABIERTO: tasa de fallo > 50%<br/>(mínimo 10 llamadas)
ABIERTO --> SEMIABIERTO: pasan 30 s
SEMIABIERTO --> CERRADO: las 3 llamadas de prueba van bien
SEMIABIERTO --> ABIERTO: alguna vuelve a fallar
note right of CERRADO: Todo pasa. Se mide.
note right of ABIERTO: Nada sale. Falla al instante<br/>y se ejecuta el fallback.
note right of SEMIABIERTO: Pasan unas pocas<br/>llamadas de prueba.
El estado ABIERTO es el que aporta el valor: se lanza CallNotPermittedException de inmediato, de modo que se deja de esperar cinco segundos por cada petición y se falla en microsegundos, sin tocar al sistema caído.
@CircuitBreaker(name = "pasarelaPagos", fallbackMethod = "cobroDiferido")
@Retry(name = "pasarelaPagos")
public ResultadoCobro cobrar(Long alquilerId, BigDecimal importe) {
RespuestaCobro respuesta = cliente.cobrar(
new SolicitudCobro(alquilerId, importe, "cobro-alquiler-" + alquilerId));
return ResultadoCobro.cobrado(respuesta.referencia());
}
/** Se ejecuta cuando el circuito está abierto o la llamada falla definitivamente. */
private ResultadoCobro cobroDiferido(Long alquilerId, BigDecimal importe, Throwable causa) {
log.warn("Pasarela no disponible ({}), se difiere el cobro del alquiler {}",
causa.toString(), alquilerId);
colaCobrosPendientes.encolar(new CobroPendiente(alquilerId, importe, Instant.now(reloj)));
return ResultadoCobro.diferido();
}Cuatro reglas sobre el fallbackMethod, todas fuente de errores frecuentes:
- Misma firma más un último parámetro
Throwable. Si no coincide, Resilience4j no lo encuentra y la excepción original escapa, en silencio, en tiempo de ejecución. - Debe estar en la misma clase y puede ser
private. - Puede haber varios, especializados por tipo de excepción; gana el más específico.
- No debe fallar ni ser lento. Un respaldo que llama a otro servicio remoto reintroduce el problema que venía a resolver.
Los dos umbrales. failureRateThreshold: 50 abre el circuito con la mitad de fallos, pero slowCallRateThreshold: 80 con slowCallDurationThreshold: 3s es igual de importante —un servicio lento hace más daño que uno caído, porque retiene hilos sin devolver nada—, y minimumNumberOfCalls: 10 evita que dos fallos en un momento de bajo tráfico abran el circuito.
- Limitador de tasa, mamparo y tiempo límite
@RateLimiter protege de superar una cuota: la pasarela de Ribalta admite 50 peticiones por segundo y sobrepasarlas produce 429 y, en algunos contratos, un recargo. Con timeoutDuration: 200ms, una llamada que no obtiene permiso en ese plazo lanza RequestNotPermitted. Es distinto del Bucket4j de 05-05: aquel limitaba lo que entra a CicloUrbana; este limita lo que sale hacia el tercero.
@Bulkhead —mamparo, por los compartimentos estancos de un barco— limita las llamadas concurrentes a un recurso: con @Bulkhead(name = "pasarelaPagos", type = Bulkhead.Type.SEMAPHORE) y maxConcurrentCalls: 10, como mucho diez hilos esperan a la pasarela a la vez; el resto falla rápido en lugar de acumularse. Es lo que impide que un tercero lento agote el pool de Tomcat: sin mamparo, doscientas peticiones concurrentes a un servicio de cinco segundos retienen los doscientos hilos y tumban CicloUrbana entera. Es el mismo principio que aplicamos en 07-03 al dar un ejecutor propio al correo.
@TimeLimiter impone un tope global, y solo funciona sobre métodos que devuelven CompletableFuture, porque necesita poder cancelar:
@TimeLimiter(name = "pasarelaPagos")
@CircuitBreaker(name = "pasarelaPagos", fallbackMethod = "cobroDiferidoAsync")
public CompletableFuture<ResultadoCobro> cobrarAsync(Long alquilerId, BigDecimal importe) {
return CompletableFuture.supplyAsync(() -> cobrar(alquilerId, importe), ejecutorPagos);
}En una aplicación bloqueante como CicloUrbana los tiempos de espera del RestClient (apartado 3) cubren el caso habitual; @TimeLimiter aporta el tope de la operación completa, incluidos los reintentos y sus pausas, que es justo lo que las esperas individuales no acotan.
- El orden de los decoradores
Cuando varias anotaciones se aplican al mismo método, el orden importa mucho. El que aplica Resilience4j por defecto, de fuera hacia dentro:
Se lee así: primero se pide sitio en el mamparo; dentro, el tiempo límite acota toda la operación; después el limitador concede permiso; luego el cortacircuitos decide si se intenta siquiera; y el reintento queda en el interior, envolviendo únicamente la llamada real. Que Retry esté dentro de CircuitBreaker es la decisión clave:
| Orden | Consecuencia |
|---|---|
CircuitBreaker fuera, Retry dentro (por defecto) |
Con el circuito abierto no se reintenta nada: se falla al instante. Cada grupo de reintentos cuenta como un resultado para la estadística del circuito |
Retry fuera, CircuitBreaker dentro |
Se reintentaría contra un circuito abierto, gastando tiempo para nada; y tres fallos de una misma operación contarían como tres, abriendo el circuito antes de tiempo |
El orden por defecto es el correcto en casi todos los casos, y se puede cambiar con las propiedades ...retryAspectOrder y ...circuitBreakerAspectOrder si hiciera falta.
- Degradación elegante
Llega la pregunta central de la lección: ¿qué hace CicloUrbana cuando la pasarela de pagos no responde?
| Opción | Consecuencia para el ciudadano | Consecuencia para el ayuntamiento |
|---|---|---|
Devolver 503 y no finalizar el alquiler |
No puede devolver la bicicleta; el anclaje queda ocupado | La red se paraliza por un fallo de un tercero |
| Finalizar y encolar el cobro | Devuelve la bicicleta con normalidad | Riesgo de impago si el cobro falla después |
| Finalizar sin cobrar nunca | Servicio gratis | Pérdida directa |
La decisión de CicloUrbana es la segunda: finalizar el alquiler y diferir el cobro, que es lo que hace el cobroDiferido del apartado 9. El razonamiento es de negocio, no técnico: el ciudadano ya devolvió la bicicleta —eso es un hecho físico que ocurrió— y bloquear la operación no lo deshace, solo deja un anclaje inutilizado y un ciudadano atrapado. El importe es pequeño, la identidad del ciudadano es conocida y el cobro se puede reintentar más tarde con una tarea programada (07-03) sobre los CobroPendiente encolados.
Y esa decisión no la toma el desarrollador: cambiaría por completo si el importe fuera de mil euros, si el ciudadano fuera anónimo o si la normativa exigiera cobro previo. La regla general es que el respaldo es una decisión de producto y el código solo la implementa, y las preguntas que hay que llevar a esa conversación son siempre las mismas: ¿podemos servir datos algo antiguos?, ¿aceptar la operación y completarla después?, ¿ofrecer una funcionalidad reducida?, ¿o esta operación es realmente indispensable?
Un catálogo de degradaciones típicas: servir la última respuesta cacheada (09-02) cuando el dato tolera antigüedad; devolver un valor por defecto seguro; encolar y confirmar, como aquí; o deshabilitar la funcionalidad manteniendo el resto.
- Observabilidad de la resiliencia
Un cortacircuitos que se abre sin que nadie se entere es tan malo como no tenerlo: el sistema degrada en silencio. Con registerHealthIndicator: true, cada circuito aparece como componente de salud en /actuator/health (07-01), y Resilience4j añade sus propios endpoints:
curl -s -u admin:*** http://localhost:8081/actuator/circuitbreakers
# { "circuitBreakers": { "pasarelaPagos": { "state": "OPEN", "failureRate": "72.0%",
# "slowCallRate": "15.0%", "bufferedCalls": 20, "failedCalls": 14 } } }
curl -s -u admin:*** http://localhost:8081/actuator/circuitbreakerevents
curl -s -u admin:*** http://localhost:8081/actuator/retriesCuidado con el indicador de salud. Si el circuito de la pasarela entra en el grupo readiness, abrirse sacaría la instancia del balanceador —justo el escenario que advertimos en 07-01—. Un cortacircuitos abierto significa «el tercero está caído y estoy degradando correctamente», no «estoy enfermo»: debe verse en /actuator/health y disparar una alerta, pero no estar en readiness.
Las métricas que Resilience4j publica en Micrometer —resilience4j_circuitbreaker_state, ..._calls, resilience4j_retry_calls— se explotan en 09-03 y se representan en Grafana en 09-04, con dos alertas mínimas: circuito abierto más de un minuto y tasa de reintentos por encima de lo normal, que es el aviso temprano de una degradación antes de que el circuito llegue a abrirse.
- Probar los fallos con WireMock
Todo lo anterior solo sirve si está probado, y no se puede probar pidiendo al proveedor que se caiga. WireMock levanta un servidor HTTP que finge ser la pasarela y se comporta como se le indique (dependencia org.wiremock:wiremock-standalone, ámbito test).
@SpringBootTest
@ActiveProfiles("test")
class ResilienciaPasarelaIT {
static WireMockServer pasarela = new WireMockServer(options().dynamicPort());
@BeforeAll static void arrancar() { pasarela.start(); }
@AfterAll static void parar() { pasarela.stop(); }
@DynamicPropertySource
static void propiedades(DynamicPropertyRegistry registro) {
registro.add("ciclourbana.pasarela.url", () -> pasarela.baseUrl() + "/api/v1");
}
@Autowired ServicioPagos servicioPagos;
@Autowired CircuitBreakerRegistry registro;
@BeforeEach void limpiar() {
pasarela.resetAll();
registro.circuitBreaker("pasarelaPagos").reset();
}
@Test
void abreElCircuitoTrasFallosSostenidosYRespondeElRespaldo() {
pasarela.stubFor(post(urlPathEqualTo("/api/v1/pagos"))
.willReturn(aResponse().withStatus(500)));
for (int i = 0; i < 12; i++) {
servicioPagos.cobrar((long) i, new BigDecimal("4.80"));
}
assertThat(registro.circuitBreaker("pasarelaPagos").getState())
.isEqualTo(CircuitBreaker.State.OPEN);
pasarela.resetRequests();
ResultadoCobro resultado = servicioPagos.cobrar(99L, new BigDecimal("4.80"));
assertThat(resultado.estado()).isEqualTo(EstadoCobro.DIFERIDO);
pasarela.verify(0, postRequestedFor(urlPathEqualTo("/api/v1/pagos")));
}
@Test
void laPasarelaLentaNoBloqueaAlCiudadano() {
pasarela.stubFor(post(urlPathEqualTo("/api/v1/pagos"))
.willReturn(okJson("{}").withFixedDelay(30_000)));
long inicio = System.currentTimeMillis();
ResultadoCobro resultado = servicioPagos.cobrar(7L, new BigDecimal("4.80"));
assertThat(resultado.estado()).isEqualTo(EstadoCobro.DIFERIDO);
assertThat(System.currentTimeMillis() - inicio).isLessThan(20_000);
}
}Los asertos que dan valor a estas pruebas son los que no miran el resultado feliz: verify(0, ...) con el circuito abierto demuestra que no salió ni una petición, que es la esencia del patrón, y la comprobación de tiempo demuestra que un tercero que tarda treinta segundos no arrastra a CicloUrbana. Para el reintento, WireMock ofrece escenarios con estado (inScenario(...).whenScenarioStateIs(STARTED)...willSetStateTo(...)), que permiten simular «falla una vez y luego funciona» y comprobarlo con verify(2, ...); el ejercicio 2 lo desarrolla.
Dos detalles imprescindibles: reiniciar el cortacircuitos entre pruebas, porque su estado es global al contexto y una prueba dejaría el circuito abierto para la siguiente; y bajar los umbrales en application-test.yml (minimumNumberOfCalls: 5, waitDurationInOpenState: 1s) para que la suite dure segundos y no minutos. La alternativa a WireMock es MockServer sobre Testcontainers, retomando 06-05, útil cuando se quiere el mismo enfoque de contenedores del resto de la suite.
- Comunicación asíncrona: el mínimo imprescindible
Cuando la respuesta no hace falta para continuar (07-05), la mensajería convierte una caída en un retraso. Con spring-kafka —o spring-boot-starter-amqp para RabbitMQ—, publicar y consumir son unas pocas líneas:
@Component
public class ConsumidorFacturacion {
@KafkaListener(topics = "ciclourbana.alquileres", groupId = "facturacion")
@Transactional
public void alFinalizarAlquiler(AlquilerFinalizado evento) {
if (procesados.existsById(evento.eventoId())) {
return; // idempotencia (07-05)
}
procesados.save(new EventoProcesado(evento.eventoId()));
servicioPagos.cobrar(evento.alquilerId(), evento.importe());
}
}La publicación es simétrica —kafka.send("ciclourbana.alquileres", String.valueOf(evento.alquilerId()), evento)— con el alquilerId como clave de partición para que los eventos de un mismo alquiler conserven el orden.
| Aspecto | Qué decidir |
|---|---|
| Serialización | JSON es lo habitual; Avro o Protobuf con esquema versionado cuando el contrato debe evolucionar sin romper consumidores |
| Idempotencia del consumidor | Obligatoria: la entrega es «al menos una vez» |
| Reintentos y cola de fallidos | Con DefaultErrorHandler y DeadLetterPublishingRecoverer, tras N intentos el mensaje va a ...DLT |
| Clave de partición | Garantiza el orden dentro de una misma entidad |
La cola de mensajes fallidos merece un párrafo. Sin ella, un mensaje que siempre falla —un evento con un campo corrupto— se reintenta indefinidamente y bloquea toda la partición: ningún mensaje posterior se procesa. Con ella, el mensaje problemático se aparta a un tema .DLT para revisión manual y el flujo continúa. Y ese tema hay que vigilarlo: una cola de fallidos que nadie mira es una carpeta de correo que nadie abre.
Errores Comunes y Consejos
No configurar los tiempos de espera. El error más caro: un tercero lento agota los hilos y tumba CicloUrbana entera.
Crear un cliente HTTP en cada llamada. Se pierde el pool de conexiones y cada petición paga una negociación TLS completa.
Reintentar un POST no idempotente. Un tiempo de espera agotado no significa que la operación no se ejecutara: se cobra dos veces.
Contar los errores de negocio como fallos del circuito. Mil tarjetas sin saldo abrirían el cortacircuitos y dejarían sin cobros a toda la red. ignoreExceptions.
Firma del fallbackMethod incorrecta. Resilience4j no lo encuentra y la excepción original escapa, sin ningún error en compilación.
Un respaldo que llama a otro servicio remoto. Reintroduce exactamente el problema que venía a resolver.
Dejar escapar la excepción del transporte. HttpClientErrorException en un servicio de dominio acopla la aplicación a la biblioteca HTTP.
Propagar el JWT del ciudadano a un tercero. Es una filtración de credenciales: hacia fuera va la clave del sistema, no la del usuario.
Meter el cortacircuitos en el grupo readiness. Abrirse significa que el tercero está caído, no que la instancia esté enferma.
Consejo: un cliente, un cortacircuitos y un mamparo por destino, porque compartirlos hace que un tercero lento afecte a llamadas que no tienen nada que ver; y fija los tiempos desde el presupuesto de latencia de tu endpoint, no desde lo que suele tardar el remoto.
Consejo: prueba los fallos, no solo el camino feliz. Un cortacircuitos que nunca se ha visto abrir en una prueba es una hipótesis, no una protección.
Ejercicios
Ejercicio 1: cliente resiliente del servicio de estaciones
servicio-alquileres necesita consultar a servicio-estaciones si la bicicleta RB-0142 está disponible antes de iniciar un alquiler. Diseña el cliente completo: interfaz @HttpExchange, RestClient con tiempos de espera y propagación del JWT, configuración de Resilience4j y respaldo. La consulta es de solo lectura y el endpoint de alquiler debe responder en menos de un segundo. Justifica cada valor y decide qué debe pasar si estaciones no responde.
Ejercicio 2: la prueba que demuestra la protección
Escribe las pruebas con WireMock que demuestren, para el cliente del ejercicio anterior: que un 503 puntual se reintenta y acaba funcionando; que tras fallos sostenidos el circuito abre y deja de salir tráfico; que una respuesta que tarda 20 segundos no bloquea al ciudadano; y que un 404 (bicicleta inexistente) no cuenta como fallo del circuito. Indica la configuración de application-test.yml necesaria.
Ejercicio 3: revisar un cliente de producción
Encuentra todos los problemas de este código y reescríbelo.
@Service
public class ServicioPagos {
@Retry(name = "pagos", fallbackMethod = "respaldo")
public String cobrar(Long alquilerId, BigDecimal importe) {
RestTemplate rest = new RestTemplate();
String url = "https://pagos.ribalta.example/api/v1/pagos?apiKey=" + apiKey;
ResponseEntity<String> r = rest.postForEntity(url,
Map.of("alquiler", alquilerId, "importe", importe), String.class);
if (r.getStatusCode() != HttpStatus.OK) {
throw new RuntimeException("Error: " + r.getBody());
}
return this.extraerReferencia(r.getBody());
}
private String respaldo(Long alquilerId) {
return null;
}
}Soluciones
Solución 1
@HttpExchange(url = "/estaciones", accept = "application/json")
public interface ClienteEstaciones {
@GetExchange("/bicicletas/{matricula}/disponibilidad")
DisponibilidadBicicleta consultarDisponibilidad(@PathVariable String matricula);
}@Bean
RestClient clienteEstaciones(EstacionesProperties props,
InterceptorTraza traza, InterceptorJwt jwt) {
var ajustes = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofMillis(300))
.withReadTimeout(Duration.ofMillis(600));
return RestClient.builder()
.baseUrl(props.url())
.requestFactory(ClientHttpRequestFactories.get(ajustes))
.requestInterceptor(traza)
.requestInterceptor(jwt) // servicio propio: el token relay es correcto
.build();
}resilience4j:
circuitbreaker:
instances:
estaciones:
slidingWindowSize: 20
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallDurationThreshold: 500ms # en el camino crítico, lento = roto
slowCallRateThreshold: 60
waitDurationInOpenState: 15s
ignoreExceptions: [com.ciclourbana.estaciones.BicicletaNoEncontradaException]
retry:
instances:
estaciones: { maxAttempts: 2, waitDuration: 100ms, enableRandomizedWait: true }
bulkhead:
instances:
estaciones: { maxConcurrentCalls: 30, maxWaitDuration: 50ms }@CircuitBreaker(name = "estaciones", fallbackMethod = "sinInformacion")
@Retry(name = "estaciones")
@Bulkhead(name = "estaciones")
public DisponibilidadBicicleta consultar(String matricula) {
return cliente.consultarDisponibilidad(matricula);
}
private DisponibilidadBicicleta sinInformacion(String matricula, Throwable causa) {
log.warn("servicio-estaciones no disponible ({}): se deniega el alquiler de {}",
causa.toString(), matricula);
throw new ServicioEstacionesNoDisponibleException(matricula); // -> 503 con Retry-After
}Justificación de cada valor. El presupuesto es de un segundo para todo el endpoint, y esta consulta es solo una parte de él: de ahí 300 ms de conexión y 600 de lectura, con dos intentos como máximo y 100 ms de pausa, lo que acota el peor caso en unos 1,6 s antes de que actúe el respaldo —ya fuera de presupuesto, y por eso el circuito es agresivo—. slowCallDurationThreshold: 500ms es bajo a propósito: en el camino crítico, lento equivale a roto. waitDurationInOpenState: 15s es corto porque es un servicio propio que se recupera rápido tras un despliegue. ignoreExceptions con la bicicleta inexistente evita que un 404 legítimo cuente como fallo. Y el mamparo de 30 llamadas concurrentes protege el pool de Tomcat.
Qué pasa si estaciones no responde, y por qué. Aquí la decisión es la contraria a la de los pagos: se deniega el alquiler con un 503. La diferencia es que el cobro diferido tenía compensación posible —se cobra más tarde— mientras que iniciar un alquiler sin saber si la bicicleta está disponible no la tiene: dos ciudadanos podrían llevarse la misma bicicleta, o alguien alquilar una que está en el taller. Cuando la información que falta es la que hace correcta la operación, la degradación correcta es rechazar. Es la misma pregunta del apartado 12 con distinta respuesta, y por eso se decide caso a caso.
Solución 2
# application-test.yml — umbrales bajos para que las pruebas duren segundos
resilience4j:
circuitbreaker:
instances:
estaciones:
slidingWindowSize: 6
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 1s
slowCallDurationThreshold: 300ms
retry:
instances:
estaciones: { maxAttempts: 2, waitDuration: 50ms }private static final String RUTA = "/estaciones/bicicletas/RB-0142/disponibilidad";
@Test
void unFalloPuntualSeReintentaYAcabaFuncionando() {
estaciones.stubFor(get(RUTA).inScenario("r").whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(503)).willSetStateTo("ok"));
estaciones.stubFor(get(RUTA).inScenario("r").whenScenarioStateIs("ok")
.willReturn(okJson("{\"matricula\":\"RB-0142\",\"disponible\":true}")));
assertThat(servicio.consultar("RB-0142").disponible()).isTrue();
estaciones.verify(2, getRequestedFor(urlEqualTo(RUTA)));
}
@Test
void trasFallosSostenidosElCircuitoAbreYNoSaleTrafico() {
estaciones.stubFor(get(RUTA).willReturn(aResponse().withStatus(500)));
for (int i = 0; i < 6; i++) {
assertThatThrownBy(() -> servicio.consultar("RB-0142"))
.isInstanceOf(ServicioEstacionesNoDisponibleException.class);
}
assertThat(registro.circuitBreaker("estaciones").getState()).isEqualTo(State.OPEN);
estaciones.resetRequests();
assertThatThrownBy(() -> servicio.consultar("RB-0142"))
.isInstanceOf(ServicioEstacionesNoDisponibleException.class);
estaciones.verify(0, getRequestedFor(urlEqualTo(RUTA)));
}
@Test
void unaRespuestaLentaNoBloqueaAlCiudadano() {
estaciones.stubFor(get(RUTA).willReturn(okJson("{}").withFixedDelay(20_000)));
long inicio = System.currentTimeMillis();
assertThatThrownBy(() -> servicio.consultar("RB-0142"))
.isInstanceOf(ServicioEstacionesNoDisponibleException.class);
assertThat(System.currentTimeMillis() - inicio).isLessThan(3_000);
}
@Test
void unaBicicletaInexistenteNoCuentaComoFalloDelCircuito() {
estaciones.stubFor(get(urlPathMatching(".*/disponibilidad"))
.willReturn(aResponse().withStatus(404)));
for (int i = 0; i < 6; i++) {
assertThatThrownBy(() -> servicio.consultar("RB-9999"))
.isInstanceOf(BicicletaNoEncontradaException.class);
}
assertThat(registro.circuitBreaker("estaciones").getState()).isEqualTo(State.CLOSED);
}Comentarios. La primera usa escenarios con estado de WireMock, la única forma de simular «falla una vez y luego funciona», y el verify(2, ...) es lo que prueba el reintento; sin él, la prueba pasaría igual sin ningún reintento configurado. La segunda contiene el aserto más valioso del ejercicio: verify(0, ...) tras resetRequests() demuestra que con el circuito abierto no sale ni una petición, que es precisamente el objetivo del patrón. La tercera comprueba que 20 segundos de retraso se cortan en menos de 3, lo que prueba que la espera de lectura está configurada. Y la cuarta es la más sutil: verifica que un error de negocio no degrada el sistema; sin ignoreExceptions, seis consultas a una matrícula inexistente dejarían sin servicio a toda la red de Ribalta.
Cada prueba parte de estaciones.resetAll() y registro.circuitBreaker("estaciones").reset() en un @BeforeEach; sin eso, el orden de ejecución determinaría el resultado y la suite sería intermitente.
Solución 3
Nueve problemas:
| # | Problema | Consecuencia |
|---|---|---|
| 1 | new RestTemplate() en cada llamada |
Sin pool: negociación TLS completa por petición; y sin ningún tiempo de espera, así que un remoto lento retiene el hilo indefinidamente |
| 2 | La apiKey en la URL |
Queda en los logs de acceso, en los proxies intermedios y en el historial: es una credencial filtrada. Debe ir en una cabecera |
| 3 | @Retry sobre un POST sin clave de idempotencia |
Un cobro puede aplicarse dos veces si el fallo ocurre tras cobrar |
| 4 | RuntimeException genérica |
El manejador de 03-06 no puede distinguir «tarjeta sin saldo» de «pasarela caída»: todo acaba en 500 |
| 5 | r.getBody() en el mensaje de la excepción |
El cuerpo de una pasarela de pagos puede contener datos sensibles, y acaba en la respuesta al cliente |
| 6 | Firma del fallbackMethod incorrecta |
Le falta BigDecimal importe y el Throwable final: Resilience4j no lo encuentra y la excepción escapa |
| 7 | El respaldo devuelve null |
Traslada el fallo a un NullPointerException en otro punto, mucho más difícil de diagnosticar |
| 8 | this.extraerReferencia(...) |
Autoinvocación: irrelevante aquí porque el método no está anotado, pero es el hábito que rompe @Retry y @CircuitBreaker |
| 9 | Sin cortacircuitos ni mamparo | Solo hay reintentos, que ante un fallo sostenido triplican la carga sobre un sistema ya caído |
La versión corregida es la del cuerpo de la lección: RestClient como bean con los tiempos de PasarelaProperties, la clave en una cabecera por defecto, la interfaz @HttpExchange, el defaultStatusHandler que traduce a PagoRechazadoException / PasarelaNoDisponibleException, @CircuitBreaker + @Retry + @Bulkhead con ignoreExceptions sobre los errores de negocio, la clave de idempotencia cobro-alquiler-{id} en cada solicitud, y un cobroDiferido(Long, BigDecimal, Throwable) que encola el cobro y devuelve ResultadoCobro.diferido() —nunca null—.
Conclusión
El módulo 7 termina y CicloUrbana ha cambiado de naturaleza. Empezó siendo una aplicación correcta: bien construida, bien probada y completamente incapaz de vivir fuera del portátil de un desarrollador. Termina siendo una aplicación operable. Actuator responde si está sana, qué versión corre y qué está fallando, con sondas de disponibilidad que un orquestador entiende. Los perfiles hacen que el mismo artefacto —el que aprobó ./mvnw verify— sirva para el portátil y para el servidor del ayuntamiento sin recompilarse, con los secretos fuera del repositorio. Las tareas programadas caducan los alquileres olvidados de Ribalta y recalculan la ocupación, coordinadas entre instancias; la ejecución asíncrona envía el correo de confirmación sin hacer esperar al ciudadano, con la traza y la identidad viajando al otro hilo. La imagen de contenedor empaqueta la aplicación, su JRE y su zona horaria en un artefacto reproducible, con capas que hacen que reconstruir cueste segundos, un usuario sin privilegios y un docker-compose.yml donde PostgreSQL y la aplicación se esperan por sus comprobaciones de salud. Y esta última lección ha añadido la pieza que faltaba: CicloUrbana ya sabe hablar con el mundo exterior sin morir en el intento —tiempos de espera que acotan la espera, reintentos con retroceso y jitter solo donde es seguro reintentar, un cortacircuitos que deja de insistir sobre lo que está caído, mamparos que impiden que un tercero lento agote los hilos, y una degradación elegante que es una decisión de negocio, escrita en código y demostrada con pruebas que fingen la caída—. Sabe además cuándo tiene sentido dividirse en microservicios y, más valioso todavía, cuándo no. Lo que queda ya no es construir, sino entregar: la red de Ribalta funciona, es observable, resiste y está empaquetada, pero sigue viviendo en máquinas de desarrollo. El módulo 8, Despliegue de Aplicaciones Spring Boot, la pone por fin en manos de los ciudadanos: qué significa desplegar y qué hay que decidir antes de hacerlo, una primera plataforma sencilla con Heroku, la infraestructura real en AWS, la orquestación con Kubernetes —donde las sondas de 07-01 y la imagen de 07-04 encajan por fin en su sitio— y una canalización de integración y entrega continua que lleve cada commit desde el repositorio hasta la ciudad sin que nadie toque un servidor a mano.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
