Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado publicar el acceso con jwt sin perder permisos. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
La integración responde cuando el proveedor funciona. Hoy definirás qué pasa cuando tarda demasiado o falla. Un timeout limita la espera; una caché reutiliza temporalmente un resultado y necesita una política de caducidad. La respuesta debe indicar cuándo faltan datos o se usan datos anteriores.
La falacia de la red fiable y el colapso de hilos
En los años 90, los ingenieros de Sun Microsystems formularon las famosas 8 Falacias de la Computación Distribuida. Las dos primeras dicen:
- «La red es fiable.» (Falso: los cables se cortan, los servidores remotos se saturan y los firewalls descartan paquetes).
- «La latencia es cero.» (Falso: cruzar Internet siempre cuesta tiempo).
Si no configuras límites en tus peticiones HTTP salientes, estás cometiendo una negligencia crítica:
- Por defecto, muchas librerías HTTP esperan de forma indefinida o con timeouts gigantescos (de minutos).
- El ataque de denegación de servicio involuntario (Thread Starvation): Tomcat dispone de un pool de hilos de trabajo (por defecto 200 hilos). Cada petición HTTP entrante consume un hilo mientras espera la respuesta.
- Si Open-Meteo sufre una caída y tarda 30 segundos en responder, y entran 200 peticiones a
/proyectos, los 200 hilos de Tomcat se quedan bloqueados esperando a Open-Meteo. - En ese instante, tu servidor deja de atender cualquier otra petición: nadie puede hacer login, nadie puede consultar tareas locales y tu aplicación entera se cae como un castillo de naipes.
La ley de la resiliencia en integraciones
El fallo de un servicio de terceros nunca debe arrastrar a la caída de tu propio sistema.
Toda llamada HTTP saliente debe tener tiempos límite estrictos (timeouts de pocos segundos) y una estrategia de degradación elegante ante indisponibilidad.
Configuración obligatoria: Connect Timeout y Read Timeout
Debemos configurar dos límites independientes en la factoría de conexiones HTTP:
- Petición saliente
- Connect Timeout (máx 2s para TCP/TLS)
- Conexión establecida
- Read Timeout (máx 3s para recibir bytes)
- Respuesta completa
- Connect Timeout: Tiempo máximo permitido para establecer el socket TCP y completar la negociación TLS/HTTPS con el servidor remoto (ej: 2 segundos). Si la IP no responde o el firewall descarta los paquetes SYN, se aborta.
- Read Timeout: Tiempo máximo de inactividad entre paquetes de datos una vez establecida la conexión (ej: 3 segundos). Si el servidor remoto aceptó la conexión pero se queda calculando indefinidamente, se corta.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre el cliente externo, su configuración y el servicio que lo llama. Ejecuta primero el caso correcto y observa su duración.
- Define cuánto puede esperar tu operación y qué parte puede seguir funcionando sin el dato externo. Anota qué respuesta esperará el cliente.
- Prepara en desarrollo una URL inaccesible o una simulación controlada del proveedor; evita depender de una caída real para probar.
Paso 2 · Configuración de timeouts y degradación elegante
En ClimaProyectoResponse cambia double por Double para temperatura y viento, y boolean por Boolean para la valoración. Estos tipos admiten null: significa que no hay medición, mientras que cero grados sí sería una medición. En el cliente comprueba primero si temperatura es null y muestra el aviso. Si añadiste recomendacion en la sesión 41, conserva ese componente y pasa también un texto de indisponibilidad al construir el resultado degradado.
Sustituye únicamente la construcción del bean RestClient por la versión con requestFactory y conserva su nombre, para que los servicios reciban el mismo cliente configurado. En ClimaService actualiza el método y sus llamadas en ProyectoService o el controlador; declarar consultarClimaSeguro no cambia los sitios que todavía llamen al método antiguo. Prueba primero el caso correcto y después el fallo controlado. Un resultado sin información meteorológica debe distinguirse explícitamente de una temperatura real de cero grados.
package com.ejemplo.gestor.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
public class RestClientConfig {
@Value("${app.integraciones.open-meteo.base-url:https://api.open-meteo.com}")
private String openMeteoBaseUrl;
@Value("${app.integraciones.open-meteo.connect-timeout-ms:2000}")
private int connectTimeoutMs;
@Value("${app.integraciones.open-meteo.read-timeout-ms:3000}")
private int readTimeoutMs;
@Bean
public RestClient openMeteoRestClient() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofMillis(connectTimeoutMs));
factory.setReadTimeout(Duration.ofMillis(readTimeoutMs));
return RestClient.builder()
.baseUrl(openMeteoBaseUrl)
.requestFactory(factory)
.build();
}
}
Protegemos la llamada con un bloque try-catch específico que captura fallos de red (ResourceAccessException) y errores HTTP del servidor remoto (HttpStatusCodeException):
package com.ejemplo.gestor.integration;
import com.ejemplo.gestor.dto.ClimaProyectoResponse;
import com.ejemplo.gestor.integration.dto.OpenMeteoResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
@Service
public class ClimaService {
private static final Logger log = LoggerFactory.getLogger(ClimaService.class);
private final RestClient openMeteoRestClient;
private final ClimaAdapter climaAdapter;
public ClimaService(RestClient openMeteoRestClient, ClimaAdapter climaAdapter) {
this.openMeteoRestClient = openMeteoRestClient;
this.climaAdapter = climaAdapter;
}
public ClimaProyectoResponse consultarClimaSeguro(double latitud, double longitud) {
try {
OpenMeteoResponse external = openMeteoRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/v1/forecast")
.queryParam("latitude", latitud)
.queryParam("longitude", longitud)
.queryParam("current_weather", true)
.build())
.retrieve()
.body(OpenMeteoResponse.class);
ClimaProyectoResponse resultado = climaAdapter.adaptar(external);
return resultado != null ? resultado : generarClimaDegradado("El proveedor no ha enviado una medición");
} catch (ResourceAccessException ex) {
// Se agotó el Connect Timeout, Read Timeout o falló la resolución DNS
log.warn("Fallo de comunicación o timeout consultando Open-Meteo: {}. Aplicando degradación.", ex.getMessage());
return generarClimaDegradado("Servicio meteorológico no disponible temporalmente (timeout de red)");
} catch (HttpStatusCodeException ex) {
// El servidor remoto respondió con código 4xx o 5xx
log.error("Open-Meteo devolvió código de error HTTP {}: {}", ex.getStatusCode(), ex.getResponseBodyAsString());
return generarClimaDegradado("Información climática no disponible (error del proveedor)");
} catch (Exception ex) {
// Cualquier otro fallo imprevisto
log.error("Error inesperado en integración meteorológica", ex);
return generarClimaDegradado("Clima no disponible");
}
}
private ClimaProyectoResponse generarClimaDegradado(String aviso) {
// Devolvemos un valor seguro por defecto sin lanzar 500 al cliente
return new ClimaProyectoResponse(
null,
null,
aviso,
null // No hay medición: no se puede afirmar si es favorable
);
}
}
Paso 3 · Simulación de fallo en Bruno
Vamos a verificar empíricamente que la degradación funciona:
- Simular IP inalcanzable (Timeout de conexión):
- En
application.properties, cambia temporalmente la URL base a una IP no enrutable con timeout de 2 segundos:app.integraciones.open-meteo.base-url=http://10.255.255.1 app.integraciones.open-meteo.connect-timeout-ms=2000
- En
- Lanza la petición en Bruno:
GET http://localhost:8080/api/v1/proyectos/1 - Observa el comportamiento:
- La petición tarda exactamente 2 segundos (el valor del
connect-timeout). - El servidor NO responde con 500 Internal Server Error.
- Responde con código
200 OK, entregando el nombre del proyecto, el cliente, las tareas y el campo:"descripcionClima": "Servicio meteorológico no disponible temporalmente (timeout de red)".
- La petición tarda exactamente 2 segundos (el valor del
- Inspecciona la consola:
Aparece un
WARNlimpio registrado en los logs sin saturar la consola con trazas descontroladas.
Paso 4 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| La petición sigue tardando 30 segundos | Los timeouts no se aplican | Deben ir en la requestFactory del RestClient, no en application.properties a secas |
Un 404 del proveedor llega al cliente como 500 |
Solo capturas ResourceAccessException |
Un error HTTP remoto es HttpStatusCodeException, que es otra rama distinta |
El catch no salta nunca al cortar la red |
Estabas mirando una respuesta cacheada | Reinicia la aplicación: la caché en memoria se vacía con ella |
@Cacheable no hace nada |
Falta @EnableCaching |
Va en la clase principal o en una @Configuration |
@Cacheable no hace nada aunque esté habilitado |
Llamada interna | Si el método se invoca desde otro método de la misma clase, el proxy no interviene |
| El clima se queda congelado durante horas | La caché no expira | Una caché sin ttl no caduca nunca: para datos que cambian, configura el tiempo de vida |
Con los timeouts configurados, comprueba el fallo del proveedor de forma controlada:
- Comenta temporalmente la
requestFactorydel bean. - Apunta el
base-urla un host que no responde, por ejemplohttp://10.255.255.1. - Lanza la petición y cronométrala. Se quedará colgada decenas de segundos.
- Mientras tanto, lanza otras cinco peticiones a
GET /api/v1/proyectos. Observa que también se ralentizan: cada llamada colgada retiene un hilo de Tomcat, y los hilos son un recurso finito. Con suficientes peticiones simultáneas, un proveedor lento tumba tu aplicación entera sin haber fallado él. - Restaura la factoría con sus timeouts y repite: ahora falla en 2 segundos, de forma controlada y sin arrastrar a nadie.
Ese es el argumento completo de la sesión: un timeout no sirve para responder rápido, sirve para que el fallo de otro no se convierta en el tuyo.
Paso 5 · Cachear respuestas climáticas para ahorrar peticiones
Una caché guarda temporalmente el resultado de una consulta para reutilizarlo. Aquí la clave son las coordenadas y el valor es el clima obtenido. Necesitamos tanto las anotaciones de Spring como un proveedor que aplique la caducidad.
- Añade estas dependencias dentro de
dependenciesenpom.xmly sincroniza Maven. Spring Boot gestiona sus versiones.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
- Crea
config/CacheConfig.javapara activar el soporte. Decláralo una sola vez.
package com.ejemplo.gestor.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {}
- Añade a
application.propertiesuna caché llamadaclima, con un máximo de 500 entradas y caducidad de diez minutos desde cada escritura.
spring.cache.type=caffeine
spring.cache.cache-names=clima
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
- En
ClimaService.java, importaorg.springframework.cache.annotation.Cacheabley coloca esta anotación justo encima de tu método existenteconsultarClimaSeguro. Conserva todo su cuerpo.
@Cacheable(value = "clima", key = "#p0 + '_' + #p1",
unless = "#result == null || #result.temperaturaCelsius() == null")
#p0 y #p1 son los dos argumentos. unless evita guardar una respuesta sin medición. El controlador u otro servicio debe llamar a este bean; llamar al método desde otro método de la misma instancia no atraviesa el mecanismo de caché de Spring.
- Añade un log justo antes de
openMeteoRestClient.get()y realiza dos peticiones con las mismas coordenadas. Ambas responden, pero el log de consulta externa debe aparecer una vez. Mide tus tiempos; no tienen por qué coincidir con los de otro ordenador. - Repite con otras coordenadas y comprueba que se consulta al proveedor de nuevo. Dos ubicaciones pueden tener la misma temperatura: la prueba de la clave es el número de consultas y sus parámetros, no que las temperaturas sean distintas.
- Para probar caducidad sin esperar diez minutos, cambia temporalmente
10mpor5s, reinicia y repite la misma petición antes y después de cinco segundos. Restaura10m. Con el proveedor simulado como caído, comprueba que la respuesta degradada no impide reintentar la consulta siguiente.
Registra el timeout de conexión, el de lectura y el tiempo de caché elegidos, junto a estas comprobaciones. La caché no elimina la necesidad de manejar fallos del proveedor.
Paso 6 · Comprobar y registrar el resultado del proyecto
- Provoca el fallo y verifica que la operación termina dentro del límite configurado y devuelve el resultado alternativo documentado.
- Repite consultas para comprobar cuándo se utiliza la caché, cuándo caduca y cómo se distingue un dato no disponible de un dato válido.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · El patrón Circuit Breaker con Resilience4j
Cuando un servicio externo está completamente caído, reintentar la conexión 200 veces por segundo sigue consumiendo 2 segundos de timeout en cada petición.
Investiga la librería Resilience4j:
- ¿Qué es un Disyuntor (Circuit Breaker) y cuáles son sus tres estados (
CLOSED,OPEN,HALF_OPEN)? - ¿Por qué en estado
OPENel disyuntor corta la llamada de inmediato (en 0 ms) ejecutando el método de fallback sin tocar la red? - Diseña en un documento técnico las ventajas de incorporar Resilience4j en integraciones críticas.
Formato de entrega
Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.
ResourceAccessException y caché en memoria con @Cacheable evitando llamadas repetidas.Ver respuestas
1 · Es la saturación del pool de hilos de trabajo de Tomcat al quedar todos bloqueados esperando respuestas externas lentas, impidiendo atender cualquier otra petición entrante al servidor.
2 · Lanza org.springframework.web.client.ResourceAccessException (que envuelve un SocketTimeoutException o ConnectException).
3 · Es la capacidad de un sistema de seguir funcionando y ofreciendo su servicio principal con funcionalidad reducida o datos por defecto cuando un componente secundario falla.
4 · Reduce drásticamente la latencia para el usuario (de ~200 ms a ~1 ms), ahorra ancho de banda y peticiones contra la API externa, y permite responder con datos recientes si el proveedor sufre una caída temporal.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El servicio no queda esperando indefinidamente y las pruebas reproducen los fallos externos.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.