Guía técnica

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.

31 de marzo de 202614 min de lecturaEquipo de Ingeniería Oronts

La Taxonomia de los Fallos

Los sistemas fallan de cuatro maneras. Cada una requiere una respuesta diferente.

TipoDescripcionEjemploRespuesta correcta
TransitorioGlitch breve, se resuelve soloTimeout de red, reset de conexionRetry con backoff
PermanenteRoto hasta que alguien lo arregleConfig invalida, mismatch de schemaFail fast, alerta, no reintentar
ParcialAlgunas funcionalidades funcionanUno de tres proveedores esta caidoDegradar elegantemente, servir lo que funciona
En cascadaUn fallo desencadena otrosSobrecarga de base de datos causa timeout en todos los serviciosCircuit 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:

ContextoCuando el circuito se abreComportamiento correcto
Busqueda de productoUn proveedor caidoMostrar resultados de otros proveedores
Verificacion de precioServicio de pricing caidoMostrar precio en cache con etiqueta "al dia X"
CheckoutPasarela de pago caidaPoner el pedido en cola, procesar despues
RecomendacionServicio ML caidoMostrar productos populares en su lugar
Servicio de imagenesCDN caidoMostrar 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:

EscenarioComo testearQue verificar
Base de datos caidaDetener la base de datos en stagingEl circuit breaker se abre, se sirven datos en cache
Dependencia lentaInyectar 5s de latenciaEl timeout se dispara, se devuelve respuesta degradada
Cola llenaLlenar la cola con mensajes de pruebaSe aplica backpressure, sin perdida de datos
Presion de memoriaLimitar memoria del contenedorManejo de OOM, reinicio elegante
Expiracion de certificadoUsar certificado de corta duracion en stagingLa alerta se dispara antes de la expiracion

Errores Comunes

  1. Misma estrategia de retry para todos los fallos. Un timeout necesita backoff. Un input invalido necesita cero retries. Un rate limit necesita delay fijo.

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

  3. Sin jitter en los retries. Todos los clientes reintentan al mismo tiempo, aplastando el servicio en recuperacion. Agrega jitter aleatorio.

  4. Timeouts infinitos. Una peticion que espera para siempre bloquea una conexion y un hilo. Cada operacion necesita un presupuesto de timeout.

  5. Testear solo el happy path. Si nunca has testeado que pasa cuando la base de datos se cae, no sabes si tus fallbacks funcionan.

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

patrones de resilienciacircuit breakerestrategia de retrydegradacion elegantefiabilidad de sistemaspresupuesto de timeoutchaos engineeringdead letter queue

¿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