StackPractices
intermediate Por Mathias Paulenko

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

  1. 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.

  2. 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.

  3. Monitorea el rendimiento del cache. Rastrea ratios de aciertos, ratios de fallos y patrones de evicción para optimizar la configuración del cache.

  4. Maneja fallos de cache elegantemente. Si el cache falla, vuelve al cliente original en lugar de romper la aplicación.

  5. Documenta estrategias de invalidación de cache. Documenta claramente cuándo y cómo deben invalidarse las entradas de cache.

  6. Usa claves de cache consistentemente. Asegúrate de que las claves de cache sean deterministas e incluyan todos los parámetros relevantes.

  7. Considera el calentamiento de cache. Pre-pobla el cache con datos frecuentemente accedidos para evitar arranques en frío.

  8. Implementa métricas de cache. Agrega logging y métricas para entender el comportamiento del cache e identificar problemas.

  9. No caches respuestas de POST/PUT/DELETE. Estas operaciones tienen efectos secundarios y no deben cachearse sin consideración cuidadosa.

  10. Encripta datos sensibles en cache. Si caches información sensible, asegúrate de que esté encriptada en reposo.