TechLead
Avanzado
25 min
Lección 8 de 10
API

Mejores Prácticas de API

Aprende sobre limitación de tasa, almacenamiento en caché, seguridad, versionado y patrones de diseño de API

Construyendo Clientes de API Listos para Producción

Escribir llamadas a APIs es fácil—construir integraciones de API robustas, mantenibles y eficientes requiere seguir las mejores prácticas. Esta guía cubre patrones esenciales para aplicaciones de producción.

1. Manejo de Limitación de Tasa

Muchas APIs limitan cuántas solicitudes puedes hacer. Maneja esto elegantemente:

// Clase limitadora de tasa
class RateLimiter {
  constructor(maxRequests, windowMs) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.requests = [];
  }

  async waitForSlot() {
    const now = Date.now();
    
    // Eliminar solicitudes antiguas fuera de la ventana
    this.requests = this.requests.filter(
      time => now - time < this.windowMs
    );

    if (this.requests.length >= this.maxRequests) {
      // Esperar hasta que expire la solicitud más antigua
      const oldestRequest = this.requests[0];
      const waitTime = this.windowMs - (now - oldestRequest) + 10;
      
      console.log(`Límite de tasa alcanzado, esperando ${waitTime}ms`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
      
      return this.waitForSlot();
    }

    this.requests.push(now);
  }
}

// Cliente API con limitación de tasa
class RateLimitedClient {
  constructor(baseUrl, requestsPerMinute = 60) {
    this.baseUrl = baseUrl;
    this.limiter = new RateLimiter(requestsPerMinute, 60000);
  }

  async request(endpoint, options = {}) {
    await this.limiter.waitForSlot();

    const response = await fetch(`${this.baseUrl}${endpoint}`, options);

    // Manejar 429 Demasiadas Solicitudes
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000;
      
      console.log(`Limitado por el servidor, esperando ${waitTime}ms`);
      await new Promise(resolve => setTimeout(resolve, waitTime));
      
      return this.request(endpoint, options);
    }

    return response.json();
  }
}

// Uso
const api = new RateLimitedClient('https://api.example.com', 30);
const data = await api.request('/users');

2. Almacenamiento en Caché de Respuestas

// Caché simple en memoria
class CacheManager {
  constructor(defaultTTL = 60000) {
    this.cache = new Map();
    this.defaultTTL = defaultTTL;
  }

  set(key, value, ttl = this.defaultTTL) {
    this.cache.set(key, {
      value,
      expiry: Date.now() + ttl
    });
  }

  get(key) {
    const entry = this.cache.get(key);
    
    if (!entry) return null;
    
    if (Date.now() > entry.expiry) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.value;
  }

  has(key) {
    return this.get(key) !== null;
  }

  delete(key) {
    this.cache.delete(key);
  }

  clear() {
    this.cache.clear();
  }
}

// Cliente API con almacenamiento en caché
class CachedApiClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.cache = new CacheManager();
  }

  getCacheKey(endpoint, options) {
    return `${options.method || 'GET'}:${endpoint}`;
  }

  async get(endpoint, options = {}) {
    const cacheKey = this.getCacheKey(endpoint, { method: 'GET' });
    
    // Verificar caché primero
    const cached = this.cache.get(cacheKey);
    if (cached && !options.forceRefresh) {
      console.log('Acierto de caché:', endpoint);
      return cached;
    }

    // Obtener datos frescos
    console.log('Fallo de caché:', endpoint);
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    const data = await response.json();

    // Almacenar la respuesta en caché
    const ttl = options.cacheTTL || 60000; // 1 minuto por defecto
    this.cache.set(cacheKey, data, ttl);

    return data;
  }

  // Invalidar caché después de mutaciones
  async post(endpoint, body) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });
    
    // Invalidar entradas de caché relacionadas
    this.invalidateRelated(endpoint);
    
    return response.json();
  }

  invalidateRelated(endpoint) {
    // Ejemplo: POST a /users invalida caché GET /users
    const basePath = endpoint.split('/').slice(0, 2).join('/');
    for (const key of this.cache.cache.keys()) {
      if (key.includes(basePath)) {
        this.cache.delete(key);
      }
    }
  }
}

// Patrón SWR (Stale-While-Revalidate)
async function fetchWithSWR(key, fetchFn, ttl = 60000) {
  const cache = window.__swrCache || (window.__swrCache = new Map());
  const cached = cache.get(key);
  
  if (cached) {
    // Devolver datos obsoletos inmediatamente
    const isStale = Date.now() > cached.staleAt;
    
    if (isStale) {
      // Revalidar en segundo plano
      fetchFn().then(data => {
        cache.set(key, {
          data,
          staleAt: Date.now() + ttl
        });
      });
    }
    
    return cached.data;
  }

  // Sin caché - obtener y almacenar
  const data = await fetchFn();
  cache.set(key, {
    data,
    staleAt: Date.now() + ttl
  });
  
  return data;
}

3. Deduplicación de Solicitudes

// Prevenir solicitudes concurrentes duplicadas
class RequestDeduplicator {
  constructor() {
    this.pending = new Map();
  }

  async dedupe(key, fetchFn) {
    // Si la solicitud ya está en progreso, devolver la misma promesa
    if (this.pending.has(key)) {
      console.log('Deduplicando solicitud:', key);
      return this.pending.get(key);
    }

    // Iniciar nueva solicitud
    const promise = fetchFn().finally(() => {
      this.pending.delete(key);
    });

    this.pending.set(key, promise);
    return promise;
  }
}

// Uso
const deduper = new RequestDeduplicator();

async function getUser(id) {
  return deduper.dedupe(`user-${id}`, () => 
    fetch(`/api/users/${id}`).then(r => r.json())
  );
}

// ¡Todas estas comparten la misma solicitud!
const [user1, user2, user3] = await Promise.all([
  getUser(123),
  getUser(123),
  getUser(123)
]);
// ¡Solo se hace UNA llamada API!

4. Agrupación de Solicitudes

// Agrupar múltiples solicitudes en una
class RequestBatcher {
  constructor(batchFn, options = {}) {
    this.batchFn = batchFn;
    this.delay = options.delay || 10;
    this.maxBatchSize = options.maxBatchSize || 100;
    
    this.queue = [];
    this.timeout = null;
  }

  add(item) {
    return new Promise((resolve, reject) => {
      this.queue.push({ item, resolve, reject });
      
      if (this.queue.length >= this.maxBatchSize) {
        this.flush();
      } else if (!this.timeout) {
        this.timeout = setTimeout(() => this.flush(), this.delay);
      }
    });
  }

  async flush() {
    clearTimeout(this.timeout);
    this.timeout = null;

    if (this.queue.length === 0) return;

    const batch = this.queue.splice(0, this.maxBatchSize);
    const items = batch.map(b => b.item);

    try {
      const results = await this.batchFn(items);
      
      batch.forEach((request, index) => {
        request.resolve(results[index]);
      });
    } catch (error) {
      batch.forEach(request => {
        request.reject(error);
      });
    }
  }
}

// Uso - agrupar búsquedas de usuarios
const userBatcher = new RequestBatcher(
  async (userIds) => {
    // Una sola llamada API para todos los usuarios
    const response = await fetch('/api/users/batch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids: userIds })
    });
    return response.json();
  },
  { delay: 50, maxBatchSize: 100 }
);

// ¡Estas se agrupan en una solicitud!
const users = await Promise.all([
  userBatcher.add(1),
  userBatcher.add(2),
  userBatcher.add(3)
]);

5. Mejores Prácticas de Seguridad

// Patrones de seguridad para clientes API

// 1. Nunca exponer secretos en código cliente
// ❌ Malo
const API_KEY = 'sk_live_abc123'; // ¡En el bundle del cliente!

// ✅ Bueno - usar variables de entorno y proxy del servidor
// El cliente llama a tu servidor, el servidor llama a la API externa
async function callSecureApi() {
  return fetch('/api/proxy/external-service');
}

// 2. Validar respuestas antes de usar
function validateUser(data) {
  if (typeof data !== 'object' || data === null) {
    throw new Error('Respuesta de usuario inválida');
  }
  if (typeof data.id !== 'number') {
    throw new Error('ID de usuario inválido');
  }
  if (typeof data.email !== 'string') {
    throw new Error('Email inválido');
  }
  return data;
}

// 3. Sanitizar datos antes de mostrar
function sanitizeHtml(dirty) {
  const element = document.createElement('div');
  element.textContent = dirty;
  return element.innerHTML;
}

// 4. Usar encabezados de Política de Seguridad de Contenido
// Prevenir XSS de scripts inyectados

// 5. Proteger contra CSRF
async function securePost(endpoint, data) {
  // Obtener token CSRF de meta tag o cookie
  const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
  
  return fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify(data),
    credentials: 'same-origin' // Incluir cookies
  });
}

// 6. Implementar manejo CORS adecuado
// El servidor debe establecer encabezados apropiados:
// Access-Control-Allow-Origin: https://tudominio.com
// Access-Control-Allow-Credentials: true
// Access-Control-Allow-Methods: GET, POST, PUT, DELETE

6. Versionado de API

// Manejar versionado de API elegantemente
class VersionedApiClient {
  constructor(baseUrl, version = 'v1') {
    this.baseUrl = baseUrl;
    this.version = version;
  }

  // Anteponer versión a endpoints
  getUrl(endpoint) {
    return `${this.baseUrl}/${this.version}${endpoint}`;
  }

  async get(endpoint) {
    return fetch(this.getUrl(endpoint)).then(r => r.json());
  }

  // Actualizar versión
  upgradeVersion(newVersion) {
    this.version = newVersion;
  }
}

// Patrón de detección de características
class AdaptiveApiClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.version = null;
  }

  async detectVersion() {
    try {
      // Intentar versión más reciente primero
      const v2Response = await fetch(`${this.baseUrl}/v2/health`);
      if (v2Response.ok) {
        this.version = 'v2';
        return;
      }
    } catch {}

    this.version = 'v1'; // Respaldo
  }

  async request(endpoint, options) {
    if (!this.version) {
      await this.detectVersion();
    }

    return fetch(`${this.baseUrl}/${this.version}${endpoint}`, options);
  }
}

// Transformadores de compatibilidad hacia atrás
const responseTransformers = {
  v1: {
    user: (data) => ({
      id: data.user_id,
      name: data.user_name,
      email: data.email_address
    })
  },
  v2: {
    user: (data) => data // v2 usa nomenclatura estándar
  }
};

async function getUser(id) {
  const response = await api.get(`/users/${id}`);
  const transformer = responseTransformers[api.version].user;
  return transformer(response);
}

7. Registro y Monitoreo

// Registro de solicitud/respuesta para depuración
class LoggingApiClient {
  constructor(baseUrl, options = {}) {
    this.baseUrl = baseUrl;
    this.logging = options.logging ?? true;
    this.metrics = [];
  }

  async request(endpoint, options = {}) {
    const startTime = performance.now();
    const requestId = crypto.randomUUID();

    if (this.logging) {
      console.group(`🌐 Solicitud API: ${requestId}`);
      console.log('Endpoint:', endpoint);
      console.log('Método:', options.method || 'GET');
      if (options.body) console.log('Cuerpo:', options.body);
    }

    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`, options);
      const duration = performance.now() - startTime;
      const data = await response.json();

      // Registrar métricas
      this.metrics.push({
        requestId,
        endpoint,
        method: options.method || 'GET',
        status: response.status,
        duration,
        timestamp: new Date().toISOString()
      });

      if (this.logging) {
        console.log('Estado:', response.status);
        console.log('Duración:', `${duration.toFixed(2)}ms`);
        console.log('Respuesta:', data);
        console.groupEnd();
      }

      return data;

    } catch (error) {
      const duration = performance.now() - startTime;

      this.metrics.push({
        requestId,
        endpoint,
        method: options.method || 'GET',
        status: 0,
        error: error.message,
        duration,
        timestamp: new Date().toISOString()
      });

      if (this.logging) {
        console.error('Error:', error.message);
        console.groupEnd();
      }

      throw error;
    }
  }

  // Obtener métricas de rendimiento
  getMetrics() {
    const total = this.metrics.length;
    const errors = this.metrics.filter(m => m.status === 0 || m.status >= 400).length;
    const avgDuration = this.metrics.reduce((sum, m) => sum + m.duration, 0) / total;

    return {
      totalRequests: total,
      errorCount: errors,
      errorRate: (errors / total * 100).toFixed(2) + '%',
      averageDuration: avgDuration.toFixed(2) + 'ms',
      requests: this.metrics
    };
  }

  // Enviar métricas a analytics
  async reportMetrics() {
    const metrics = this.getMetrics();
    await fetch('/api/analytics/api-metrics', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(metrics)
    });
  }
}

8. Cliente API Completo para Producción

// Cliente API listo para producción combinando todas las mejores prácticas
class ProductionApiClient {
  constructor(config) {
    this.baseUrl = config.baseUrl;
    this.version = config.version || 'v1';
    this.timeout = config.timeout || 30000;
    
    // Inicializar ayudantes
    this.cache = new CacheManager(config.cacheTTL);
    this.rateLimiter = new RateLimiter(
      config.rateLimit || 60, 
      60000
    );
    this.deduper = new RequestDeduplicator();
    
    // Autenticación
    this.token = null;
  }

  setToken(token) {
    this.token = token;
  }

  getHeaders() {
    const headers = {
      'Content-Type': 'application/json',
      'X-API-Version': this.version
    };
    
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`;
    }
    
    return headers;
  }

  async request(method, endpoint, options = {}) {
    const url = `${this.baseUrl}/${this.version}${endpoint}`;
    const cacheKey = `${method}:${url}`;

    // Verificar caché para solicitudes GET
    if (method === 'GET' && !options.skipCache) {
      const cached = this.cache.get(cacheKey);
      if (cached) return cached;
    }

    // Deduplicar solicitudes idénticas concurrentes
    return this.deduper.dedupe(cacheKey, async () => {
      // Esperar por espacio de límite de tasa
      await this.rateLimiter.waitForSlot();

      const controller = new AbortController();
      const timeoutId = setTimeout(
        () => controller.abort(), 
        options.timeout || this.timeout
      );

      try {
        const response = await fetch(url, {
          method,
          headers: this.getHeaders(),
          body: options.body ? JSON.stringify(options.body) : undefined,
          signal: controller.signal
        });

        clearTimeout(timeoutId);

        if (!response.ok) {
          const error = new Error('Solicitud fallida');
          error.status = response.status;
          error.data = await response.json().catch(() => ({}));
          throw error;
        }

        const data = await response.json();

        // Almacenar en caché respuestas GET exitosas
        if (method === 'GET') {
          this.cache.set(cacheKey, data, options.cacheTTL);
        }

        return data;

      } catch (error) {
        clearTimeout(timeoutId);
        throw error;
      }
    });
  }

  // Métodos de conveniencia
  get(endpoint, options) {
    return this.request('GET', endpoint, options);
  }

  post(endpoint, body, options) {
    return this.request('POST', endpoint, { ...options, body });
  }

  put(endpoint, body, options) {
    return this.request('PUT', endpoint, { ...options, body });
  }

  patch(endpoint, body, options) {
    return this.request('PATCH', endpoint, { ...options, body });
  }

  delete(endpoint, options) {
    return this.request('DELETE', endpoint, options);
  }
}

// Uso
const api = new ProductionApiClient({
  baseUrl: 'https://api.example.com',
  version: 'v2',
  timeout: 15000,
  rateLimit: 100,
  cacheTTL: 60000
});

api.setToken(authToken);

const users = await api.get('/users');
const newUser = await api.post('/users', { name: 'Juan' });

💡 Lista de Verificación de Cliente API

  • Manejo de errores - Fallas elegantes con retroalimentación al usuario
  • Reintentos - Retroceso exponencial para errores transitorios
  • Tiempos de espera - Prevenir solicitudes colgadas
  • Almacenamiento en caché - Reducir solicitudes redundantes
  • Limitación de tasa - Respetar límites de API
  • Deduplicación de solicitudes - Prevenir llamadas duplicadas
  • Autenticación - Manejo seguro de tokens
  • Registro - Visibilidad de depuración y monitoreo