Disenando Sistemas para el Fallo (Porque Van a Fallar)
Patrones de respuesta a fallos para sistemas en produccion. Circuit breakers, estrategias de retry, degradacion elegante, dead letter queues, presupuestos de timeout y chaos engineering para equipos pequenos.
La Taxonomia de los Fallos
Los sistemas fallan de cuatro maneras. Cada una requiere una respuesta diferente.
| Tipo | Descripcion | Ejemplo | Respuesta correcta |
|---|---|---|---|
| Transitorio | Glitch breve, se resuelve solo | Timeout de red, reset de conexion | Retry con backoff |
| Permanente | Roto hasta que alguien lo arregle | Config invalida, mismatch de schema | Fail fast, alerta, no reintentar |
| Parcial | Algunas funcionalidades funcionan | Uno de tres proveedores esta caido | Degradar elegantemente, servir lo que funciona |
| En cascada | Un fallo desencadena otros | Sobrecarga de base de datos causa timeout en todos los servicios | Circuit breaker, liberar carga |
El error mas peligroso: tratar todos los fallos de la misma manera. Reintentar un fallo permanente desperdicia recursos. No reintentar un fallo transitorio degrada la experiencia del usuario. No detectar un fallo en cascada tumba todo el sistema.
Para como manejamos fallos especificamente en sistemas event-driven, consulta nuestro guia de arquitectura event-driven. Para fallos de sistemas IA, ve nuestro guia de modos de fallo de IA.
Circuit Breakers
Un circuit breaker previene que un servicio fallando tumbe todo lo que depende de el. Cuando un servicio downstream falla repetidamente, el circuit breaker "se abre" y cortocircuita las peticiones inmediatamente en lugar de esperar timeouts.
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold: number = 5, // fallos antes de abrir
private resetTimeout: number = 30000, // ms antes de reintentar
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.resetTimeout) {
this.state = 'half-open'; // Intentar una peticion
} else {
throw new CircuitOpenError('Circuit is open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failures = 0;
this.state = 'closed';
}
private onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
}
}
}
// Uso
const supplierBreaker = new CircuitBreaker(5, 30000);
async function checkAvailability(productId: string) {
return supplierBreaker.execute(async () => {
return await supplierApi.checkAvailability(productId);
});
}
El Error Clasico
La mayoria de los equipos implementan el circuit breaker pero no manejan el estado abierto. Cuando el circuito esta abierto, que ve el usuario? Un error 500 es la respuesta equivocada. La respuesta correcta depende del contexto:
| Contexto | Cuando el circuito se abre | Comportamiento correcto |
|---|---|---|
| Busqueda de producto | Un proveedor caido | Mostrar resultados de otros proveedores |
| Verificacion de precio | Servicio de pricing caido | Mostrar precio en cache con etiqueta "al dia X" |
| Checkout | Pasarela de pago caida | Poner el pedido en cola, procesar despues |
| Recomendacion | Servicio ML caido | Mostrar productos populares en su lugar |
| Servicio de imagenes | CDN caido | Mostrar imagen placeholder |
Estrategias de Retry
No todos los retries son iguales. La estrategia depende del tipo de fallo.
interface RetryConfig {
maxAttempts: number;
strategy: 'immediate' | 'fixed' | 'exponential' | 'none';
baseDelay: number; // ms
maxDelay: number; // ms
jitter: boolean; // aleatorizar para evitar thundering herd
}
const RETRY_CONFIGS: Record<string, RetryConfig> = {
// Transitorio: retry con backoff exponencial
network_timeout: {
maxAttempts: 3,
strategy: 'exponential',
baseDelay: 1000, // 1s, 2s, 4s
maxDelay: 10000,
jitter: true,
},
// Rate limited: retry con delay fijo
rate_limited: {
maxAttempts: 5,
strategy: 'fixed',
baseDelay: 5000, // esperar 5s entre intentos
maxDelay: 5000,
jitter: false,
},
// Conflicto de lock optimista: retry inmediato
lock_conflict: {
maxAttempts: 3,
strategy: 'immediate',
baseDelay: 0,
maxDelay: 0,
jitter: false,
},
// Fallo permanente: no reintentar
validation_error: {
maxAttempts: 1,
strategy: 'none',
baseDelay: 0,
maxDelay: 0,
jitter: false,
},
};
async function retryWithStrategy<T>(
fn: () => Promise<T>,
config: RetryConfig,
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === config.maxAttempts - 1) break;
if (config.strategy === 'none') break;
let delay = config.baseDelay;
if (config.strategy === 'exponential') {
delay = Math.min(config.baseDelay * Math.pow(2, attempt), config.maxDelay);
}
if (config.jitter) {
delay += Math.random() * delay * 0.5; // 0-50% de jitter
}
await sleep(delay);
}
}
throw lastError;
}
El jitter es critico. Sin el, todos los clientes reintentan al mismo tiempo despues de un fallo (thundering herd). El jitter distribuye los retries en una ventana de tiempo, reduciendo el pico sobre el servicio en recuperacion.
Degradacion Elegante
Cuando una dependencia falla, sirve lo que puedas en lugar de fallar completamente.
async function getProductPage(productId: string): Promise<ProductPageData> {
// Datos criticos: debe tener exito
const product = await productService.getById(productId);
if (!product) throw new NotFoundError();
// Datos no criticos: degradar elegantemente
const [reviews, recommendations, availability] = await Promise.allSettled([
reviewService.getForProduct(productId),
recommendationService.getSimilar(productId),
inventoryService.checkStock(productId),
]);
return {
product,
reviews: reviews.status === 'fulfilled' ? reviews.value : [],
recommendations: recommendations.status === 'fulfilled' ? recommendations.value : [],
availability: availability.status === 'fulfilled'
? availability.value
: { status: 'unknown', message: 'Check availability in store' },
};
}
Promise.allSettled es la clave. A diferencia de Promise.all, no falla si una promesa es rechazada. Cada resultado se resuelve independientemente. La pagina de producto se renderiza con los datos que esten disponibles.
Datos Obsoletos Son Mejores que Ningun Dato
async function getProductPrice(productId: string): Promise<PriceInfo> {
try {
const livePrice = await pricingService.getPrice(productId);
await cache.set(`price:${productId}`, livePrice, { ttl: 300 });
return livePrice;
} catch (error) {
// Servicio de pricing caido: servir precio en cache
const cached = await cache.get(`price:${productId}`);
if (cached) {
return { ...cached, stale: true, staleSince: cached.cachedAt };
}
// Tampoco hay cache: devolver precio de catalogo
const product = await productService.getById(productId);
return { price: product.listPrice, stale: true, approximate: true };
}
}
Un precio obsoleto del cache es mejor que un error 500. Un precio de catalogo aproximado es mejor que ningun precio. Ten siempre un fallback, aunque sea menos preciso.
Presupuestos de Timeout
Cada operacion tiene un presupuesto de tiempo. Si se excede el presupuesto, falla rapido en lugar de hacer esperar al usuario indefinidamente.
const TIMEOUT_BUDGETS = {
api_request: 5000, // 5s en total para cualquier peticion API
database_query: 2000, // 2s para cualquier consulta a base de datos
external_api: 3000, // 3s para llamadas a servicios externos
llm_generation: 30000, // 30s para generacion IA (streaming)
search_query: 1000, // 1s para busqueda
cache_operation: 100, // 100ms para lectura/escritura de cache
};
async function withTimeout<T>(promise: Promise<T>, budget: number, label: string): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new TimeoutError(`${label} exceeded ${budget}ms budget`)), budget);
});
return Promise.race([promise, timeout]);
}
// Uso
const results = await withTimeout(
searchService.query(userQuery),
TIMEOUT_BUDGETS.search_query,
'product_search',
);
Proteccion Contra Timeouts en Cascada
Cuando el servicio A llama al servicio B que llama al servicio C, cada servicio debe restar su propio tiempo de procesamiento del presupuesto restante:
API Gateway (presupuesto de 5s)
└── Verificacion auth (100ms usado, 4.9s restante)
└── Servicio de producto (200ms usado, 4.7s restante)
└── Servicio de pricing (timeout: 4.7s, no los 5s originales)
Sin presupuestos en cascada, el servicio de pricing usa un timeout completo de 3s aunque el gateway solo tenga 4.7s restantes. Si pricing tarda 3s, el gateway hace timeout antes de que llegue la respuesta, desperdiciando todo el trabajo realizado.
Testear los Fallos
Chaos Engineering para Equipos Pequenos
No necesitas el Chaos Monkey de Netflix para testear el manejo de fallos. Empieza con inyeccion de fallos simple:
// Middleware: inyectar fallos en staging
function chaosMiddleware(req: Request, res: Response, next: NextFunction) {
if (process.env.NODE_ENV !== 'staging') return next();
const chaos = req.headers['x-chaos'];
if (chaos === 'latency') {
setTimeout(next, 3000); // Agregar 3s de latencia
} else if (chaos === 'error') {
res.status(500).json({ error: 'Chaos: injected failure' });
} else if (chaos === 'timeout') {
// No responder en absoluto (simular servicio colgado)
} else {
next();
}
}
Escenarios de prueba que importan:
| Escenario | Como testear | Que verificar |
|---|---|---|
| Base de datos caida | Detener la base de datos en staging | El circuit breaker se abre, se sirven datos en cache |
| Dependencia lenta | Inyectar 5s de latencia | El timeout se dispara, se devuelve respuesta degradada |
| Cola llena | Llenar la cola con mensajes de prueba | Se aplica backpressure, sin perdida de datos |
| Presion de memoria | Limitar memoria del contenedor | Manejo de OOM, reinicio elegante |
| Expiracion de certificado | Usar certificado de corta duracion en staging | La alerta se dispara antes de la expiracion |
Errores Comunes
-
Misma estrategia de retry para todos los fallos. Un timeout necesita backoff. Un input invalido necesita cero retries. Un rate limit necesita delay fijo.
-
Circuit breaker sin fallback. Abrir el circuito y devolver 500 no es tolerancia a fallos. Sirve datos en cache, resultados degradados o una respuesta en cola.
-
Sin jitter en los retries. Todos los clientes reintentan al mismo tiempo, aplastando el servicio en recuperacion. Agrega jitter aleatorio.
-
Timeouts infinitos. Una peticion que espera para siempre bloquea una conexion y un hilo. Cada operacion necesita un presupuesto de timeout.
-
Testear solo el happy path. Si nunca has testeado que pasa cuando la base de datos se cae, no sabes si tus fallbacks funcionan.
-
Fallos en cascada por dependencias compartidas. Si los servicios A, B y C dependen todos de la misma base de datos, y esa base esta lenta, los tres servicios se vuelven lentos. Circuit breakers en dependencias compartidas previenen la cascada.
Puntos Clave
-
Clasifica los fallos antes de responder. Transitorio (retry), permanente (fail fast), parcial (degradar), en cascada (circuit break). Cada tipo necesita una estrategia diferente.
-
Los circuit breakers necesitan fallbacks. Abrir el circuito no es la solucion. Servir datos en cache, resultados alternativos o respuestas en cola, eso es la solucion.
-
El jitter previene el thundering herd. Aleatoriza los delays de retry. Sin jitter, los retries sincronizados hacen que la recuperacion sea mas dificil.
-
Datos obsoletos son mejores que ningun dato. Un precio en cache de hace 5 minutos es mejor que un error 500. Ten siempre un camino de fallback.
-
Los presupuestos de timeout son en cascada. Cada servicio en la cadena resta su tiempo de procesamiento del presupuesto restante. No dejes que los servicios internos usen mas tiempo del que el servicio externo tiene disponible.
-
Testea los fallos en staging. Inyeccion de fallos simple (latencia, errores, conexiones colgadas) verifica que tus patrones de resiliencia realmente funcionan.
Disenamos sistemas resilientes como parte de nuestra practica de software a medida y cloud. Si necesitas ayuda con ingenieria de fiabilidad, habla con nuestro equipo o solicita un presupuesto.
Temas cubiertos
Guías relacionadas
Modos de Fallo de la IA: Guia de Ingenieria para Produccion
Guia tecnica sobre fallos de sistemas IA en produccion. Aprende sobre alucinaciones, limites de contexto, inyeccion de prompts y deriva del modelo.
Leer guíaArquitectura Event-Driven en la Práctica: Qué Sale Realmente Mal
Patrones reales de arquitectura event-driven en producción. Event storms, loops de sincronización bidireccional, dead letters, idempotencia y elección entre Kafka, RabbitMQ, BullMQ y Symfony Messenger.
Leer guíaGuía Empresarial de Sistemas de IA Agéntica
Guia tecnica de sistemas de IA agentica en entornos empresariales. Descubre la arquitectura, capacidades y aplicaciones de agentes IA autonomos.
Leer guía¿Listo para construir sistemas de IA listos para producción?
Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.
Iniciar una conversación