Proxy Pattern para Cacheo de Respuestas de API
Como implementar un proxy de cacheo que intercepta llamadas a APIs y almacena respuestas para reducir latencia y evitar peticiones redundantes
El Proxy pattern intercepta el acceso a un objeto para agregar comportamiento sin cambiar la implementacion original. Cuando se aplica a clientes de API, se convierte en una potente capa de cacheo que almacena respuestas, reduce latencia y protege servicios de peticiones redundantes.
Cuando Usar Esto
- Las respuestas de API son costosas de computar pero se leen frecuentemente
- Quieres evitar limites de rate en APIs de terceros
- La frescura de la respuesta puede controlarse por TTL en lugar de requerimientos en tiempo real
Problema
Cada llamada a una API externa dispara una peticion de red, serializacion y deserializacion. Para datos frecuentemente accedidos pero que cambian lentamente — como tasas de cambio, catalogos de productos o permisos de usuario — esto es ineficiente y lento.
Solucion
Implementa un proxy que envuelve el cliente real de la API y almacena respuestas en cache con expiracion configurable.
// api/WeatherClient.ts
interface WeatherClient {
getForecast(city: string): Promise<Forecast>;
}
// api/OpenWeatherClient.ts
class OpenWeatherClient implements WeatherClient {
async getForecast(city: string): Promise<Forecast> {
const res = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${city}`);
return res.json();
}
}
// proxy/CachedWeatherClient.ts
class CachedWeatherClient implements WeatherClient {
private cache = new Map<string, { data: Forecast; expiry: number }>();
constructor(
private client: WeatherClient,
private ttlMs: number = 300_000
) {}
async getForecast(city: string): Promise<Forecast> {
const key = city.toLowerCase();
const cached = this.cache.get(key);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
const data = await this.client.getForecast(city);
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
return data;
}
invalidate(city: string): void {
this.cache.delete(city.toLowerCase());
}
}
Uso
const realClient = new OpenWeatherClient();
const cachedClient = new CachedWeatherClient(realClient, 600_000);
const forecast = await cachedClient.getForecast('London');
Variaciones
- Redis Proxy: Almacena cache en Redis para sistemas distribuidos
- Smart Proxy: Agrega metricas, logging y circuit breaker junto al cacheo
- Lazy Proxy: Diferencia la inicializacion de conexiones costosas hasta el primer uso
Lo que Funciona
- Configura TTL basado en volatilidad de datos, no un valor fijo para todo. Consulta invalidacion de cache patrones.
- Implementa hooks de invalidacion de cache para consistencia write-through
- Usa decorador o composicion para apilar multiples proxies
Errores Comunes
- Cachear respuestas de POST/PUT sin entender efectos secundarios
- No manejar eviccion de cache cuando crece la presion de memoria
- Devolver datos obsoletos silenciosamente sin logging
- Establecer TTL demasiado largo para datos volatiles
- No implementar limites de tamaño de cache
- Cachear datos sensibles sin encriptacion
- Ignorar el tiempo de calentamiento de cache
- No monitorear ratios de aciertos/fallos de cache
- Usar cache como almacenamiento primario en lugar de optimizacion
- No manejar fallos de cache elegantemente
Técnicas Avanzadas
Cache Multi-Nivel
Implementa una jerarquía de caches para diferentes patrones de acceso:
class MultiLevelCachedClient implements WeatherClient {
private l1Cache = new Map<string, { data: Forecast; expiry: number }>();
private l2Cache = new Map<string, { data: Forecast; expiry: number }>();
constructor(
private client: WeatherClient,
private l1TtlMs: number = 60_000,
private l2TtlMs: number = 300_000
) {}
async getForecast(city: string): Promise<Forecast> {
const key = city.toLowerCase();
const l1Cached = this.l1Cache.get(key);
if (l1Cached && l1Cached.expiry > Date.now()) {
return l1Cached.data;
}
const l2Cached = this.l2Cache.get(key);
if (l2Cached && l2Cached.expiry > Date.now()) {
this.l1Cache.set(key, { data: l2Cached.data, expiry: Date.now() + this.l1TtlMs });
return l2Cached.data;
}
const data = await this.client.getForecast(city);
this.l1Cache.set(key, { data, expiry: Date.now() + this.l1TtlMs });
this.l2Cache.set(key, { data, expiry: Date.now() + this.l2TtlMs });
return data;
}
}
Cache con Métricas
Agrega observabilidad para entender el comportamiento del cache:
class MetricsCachedClient implements WeatherClient {
private cache = new Map<string, { data: Forecast; expiry: number }>();
private hits = 0;
private misses = 0;
constructor(
private client: WeatherClient,
private ttlMs: number = 300_000
) {}
async getForecast(city: string): Promise<Forecast> {
const key = city.toLowerCase();
const cached = this.cache.get(key);
if (cached && cached.expiry > Date.now()) {
this.hits++;
return cached.data;
}
this.misses++;
const data = await this.client.getForecast(city);
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
return data;
}
getStats() {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
hitRate: total > 0 ? this.hits / total : 0
};
}
}
Cache con Límites de Tamaño (LRU)
Implementa evicción LRU para prevenir crecimiento descontrolado de memoria:
class LRUCachedClient implements WeatherClient {
private cache = new Map<string, { data: Forecast; expiry: number }>();
private accessOrder: string[] = [];
constructor(
private client: WeatherClient,
private ttlMs: number = 300_000,
private maxSize: number = 1000
) {}
async getForecast(city: string): Promise<Forecast> {
const key = city.toLowerCase();
const cached = this.cache.get(key);
if (cached && cached.expiry > Date.now()) {
this.updateAccessOrder(key);
return cached.data;
}
const data = await this.client.getForecast(city);
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
this.updateAccessOrder(key);
this.evictIfNeeded();
return data;
}
private updateAccessOrder(key: string) {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
this.accessOrder.push(key);
}
private evictIfNeeded() {
while (this.cache.size > this.maxSize) {
const lruKey = this.accessOrder.shift();
if (lruKey) {
this.cache.delete(lruKey);
}
}
}
}
Cache con Refresco en Segundo Plano
Refresca entradas de cache antes de que expiren para prevenir arranques en frío:
class RefreshingCachedClient implements WeatherClient {
private cache = new Map<string, { data: Forecast; expiry: number; refreshing: boolean }>();
constructor(
private client: WeatherClient,
private ttlMs: number = 300_000,
private refreshBeforeExpiryMs: number = 60_000
) {}
async getForecast(city: string): Promise<Forecast> {
const key = city.toLowerCase();
const cached = this.cache.get(key);
if (cached && cached.expiry > Date.now()) {
if (cached.expiry - Date.now() < this.refreshBeforeExpiryMs && !cached.refreshing) {
cached.refreshing = true;
this.refreshInBackground(key, city);
}
return cached.data;
}
const data = await this.client.getForecast(city);
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs, refreshing: false });
return data;
}
private async refreshInBackground(key: string, city: string) {
try {
const data = await this.client.getForecast(city);
this.cache.set(key, { data, expiry: Date.now() + this.ttlMs, refreshing: false });
} catch (error) {
const cached = this.cache.get(key);
if (cached) {
cached.refreshing = false;
}
}
}
}
Mejores Prácticas
-
Establece TTL apropiado basado en volatilidad de datos. Usa TTL corto para datos que cambian frecuentemente y TTL más largo para datos estables. Nunca uses un TTL único para todo.
-
Implementa límites de tamaño de cache. Caches sin límites pueden causar problemas de memoria. Usa evicción LRU o estrategias similares para gestionar memoria.
-
Monitorea el rendimiento del cache. Rastrea ratios de aciertos, ratios de fallos y patrones de evicción para optimizar la configuración del cache.
-
Maneja fallos de cache elegantemente. Si el cache falla, vuelve al cliente original en lugar de romper la aplicación.
-
Documenta estrategias de invalidación de cache. Documenta claramente cuándo y cómo deben invalidarse las entradas de cache.
-
Usa claves de cache consistentemente. Asegúrate de que las claves de cache sean deterministas e incluyan todos los parámetros relevantes.
-
Considera el calentamiento de cache. Pre-pobla el cache con datos frecuentemente accedidos para evitar arranques en frío.
-
Implementa métricas de cache. Agrega logging y métricas para entender el comportamiento del cache e identificar problemas.
-
No caches respuestas de POST/PUT/DELETE. Estas operaciones tienen efectos secundarios y no deben cachearse sin consideración cuidadosa.
-
Encripta datos sensibles en cache. Si caches información sensible, asegúrate de que esté encriptada en reposo.
Recursos Relacionados
Patrón Decorator
Añade nueva funcionalidad a objetos dinámicamente envolviéndolos. Patrón de diseño estructural para extensión flexible de comportamiento.
PatternPatrón Adapter
Convierte la interfaz de una clase en otra interfaz que los clientes esperan. Patrón de diseño estructural para compatibilidad de interfaces.
RecipeImplementar Estrategias de Invalidación de Caché
Cómo mantener la caché consistente con las bases de datos usando TTL, write-through, write-behind y patrones de invalidación event-driven.
RecipeEstrategias de Caching
Implementa estrategias de caching útiles para bases de datos, APIs y frontends usando Redis, CDNs y caches de navegador.
PatternBuilder Pattern para Objetos de Configuracion Complejos
Usa el Builder pattern para construir objetos de configuracion complejos con parametros opcionales y valores por defecto sensatos sin constructores telescopicos
PatternDecorator Pattern para Pipelines de Peticiones HTTP
Usa el Decorator pattern para componer preocupaciones transversales como logging, metricas y reintentos en pipelines de peticiones HTTP sin modificar logica central