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