Capítulo 5: Diseño Resiliente
Capítulo 5: Diseño Resiliente
“Planifica para el fallo, no para el éxito”
Introducción a la Resiliencia
La resiliencia es la capacidad de un sistema para continuar funcionando (posiblemente de forma degradada) ante fallos parciales y recuperarse cuando los problemas se resuelven.
En sistemas distribuidos, los fallos no son excepciones, son la norma. Las redes fallan, los servidores se caen, los servicios se saturan. Un sistema resiliente está diseñado asumiendo que estas cosas ocurrirán.
Los tres pilares de la resiliencia que veremos en este capítulo son:
- Reintentos: Volver a intentar operaciones que fallaron temporalmente.
- Circuit Breaker: Evitar llamar a servicios que están fallando repetidamente.
- Timeouts: No esperar indefinidamente por respuestas.
Tipos de Fallos
graph TD
F[Fallos] --> T[Transitorios]
F --> P[Permanentes]
T --> TN[Timeout de red]
T --> TC[Servicio ocupado]
T --> TR[Rate limiting]
P --> PV[Validación fallida]
P --> PN[Recurso no existe]
P --> PB[Regla de negocio violada]
Es crucial distinguir entre estos tipos:
- Fallos Transitorios: Problemas temporales que probablemente se resolverán solos. Tiene sentido reintentar.
- Fallos Permanentes: Errores que no se resolverán con reintentos. Reintentar es inútil y desperdicia recursos.
Un timeout de red es transitorio (la red puede recuperarse). Una validación fallida (datos inválidos) es permanente (reintentar con los mismos datos dará el mismo error).
El rate limiting ocurre cuando un servicio rechaza solicitudes porque estás enviando demasiadas. Es transitorio porque eventualmente te permitirá enviar más.
Estrategia de Reintentos
TypeScript
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
retryableErrors: string[];
}
class RetryPolicy {
constructor(private config: RetryConfig) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.config.maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (!this.isRetryable(error) || attempt === this.config.maxAttempts) {
throw error;
}
const delay = this.calculateDelay(attempt);
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms`);
await this.sleep(delay);
}
}
throw lastError;
}
private isRetryable(error: unknown): boolean {
if (error instanceof Error) {
return this.config.retryableErrors.some(e =>
error.message.includes(e) || error.name === e
);
}
return false;
}
private calculateDelay(attempt: number): number {
const delay = this.config.baseDelayMs * Math.pow(this.config.backoffMultiplier, attempt - 1);
const jitter = Math.random() * 0.3 * delay;
return Math.min(delay + jitter, this.config.maxDelayMs);
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Uso
const retryPolicy = new RetryPolicy({
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 10000,
backoffMultiplier: 2,
retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ServiceUnavailable']
});
const result = await retryPolicy.execute(() => inventoryService.reserve(orderId, items));
Go
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
BackoffMultiplier float64
RetryableErrors []string
}
type RetryPolicy struct {
config RetryConfig
}
func NewRetryPolicy(config RetryConfig) *RetryPolicy {
return &RetryPolicy{config: config}
}
func (rp *RetryPolicy) Execute(ctx context.Context, operation func() error) error {
var lastErr error
for attempt := 1; attempt <= rp.config.MaxAttempts; attempt++ {
if err := operation(); err == nil {
return nil
} else {
lastErr = err
if !rp.isRetryable(err) || attempt == rp.config.MaxAttempts {
return err
}
delay := rp.calculateDelay(attempt)
log.Printf("Attempt %d failed, retrying in %v", attempt, delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
}
return lastErr
}
func (rp *RetryPolicy) isRetryable(err error) bool {
errStr := err.Error()
for _, retryable := range rp.config.RetryableErrors {
if strings.Contains(errStr, retryable) {
return true
}
}
return false
}
func (rp *RetryPolicy) calculateDelay(attempt int) time.Duration {
delay := float64(rp.config.BaseDelay) * math.Pow(rp.config.BackoffMultiplier, float64(attempt-1))
jitter := rand.Float64() * 0.3 * delay
total := time.Duration(delay + jitter)
if total > rp.config.MaxDelay {
return rp.config.MaxDelay
}
return total
}
Python
import asyncio
import random
from dataclasses import dataclass
from typing import Callable, TypeVar, List
T = TypeVar('T')
@dataclass
class RetryConfig:
max_attempts: int = 3
base_delay_ms: int = 1000
max_delay_ms: int = 10000
backoff_multiplier: float = 2.0
retryable_errors: List[str] = None
def __post_init__(self):
if self.retryable_errors is None:
self.retryable_errors = ['timeout', 'connection', 'unavailable']
class RetryPolicy:
def __init__(self, config: RetryConfig):
self.config = config
async def execute(self, operation: Callable[[], T]) -> T:
last_error = None
for attempt in range(1, self.config.max_attempts + 1):
try:
return await operation()
except Exception as e:
last_error = e
if not self._is_retryable(e) or attempt == self.config.max_attempts:
raise
delay = self._calculate_delay(attempt)
print(f"Attempt {attempt} failed, retrying in {delay}ms")
await asyncio.sleep(delay / 1000)
raise last_error
def _is_retryable(self, error: Exception) -> bool:
error_str = str(error).lower()
return any(e in error_str for e in self.config.retryable_errors)
def _calculate_delay(self, attempt: int) -> float:
delay = self.config.base_delay_ms * (self.config.backoff_multiplier ** (attempt - 1))
jitter = random.random() * 0.3 * delay
return min(delay + jitter, self.config.max_delay_ms)
Circuit Breaker
El Circuit Breaker (disyuntor) es un patrón inspirado en los disyuntores eléctricos. Cuando detecta demasiadas fallas, “abre el circuito” y deja de enviar solicitudes al servicio problemático. Esto evita saturar un servicio que está en problemas y permite que se recupere.
El patrón tiene tres estados:
- Cerrado (Closed): Todo funciona normal. Las solicitudes pasan al servicio.
- Abierto (Open): Demasiadas fallas. Las solicitudes fallan inmediatamente sin llamar al servicio.
- Semi-abierto (Half-Open): Después de un tiempo, permite algunas solicitudes de prueba para ver si el servicio se recuperó.
Evita llamadas a servicios que están fallando:
stateDiagram-v2
[*] --> Closed
Closed --> Open: Umbral de fallos alcanzado
Open --> HalfOpen: Timeout expirado
HalfOpen --> Closed: Llamada exitosa
HalfOpen --> Open: Llamada fallida
TypeScript
type CircuitState = 'closed' | 'open' | 'half-open';
interface CircuitBreakerConfig {
failureThreshold: number;
resetTimeoutMs: number;
halfOpenMaxCalls: number;
}
class CircuitBreaker {
private state: CircuitState = 'closed';
private failures = 0;
private lastFailureTime = 0;
private halfOpenCalls = 0;
constructor(
private name: string,
private config: CircuitBreakerConfig
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime >= this.config.resetTimeoutMs) {
this.state = 'half-open';
this.halfOpenCalls = 0;
} else {
throw new CircuitOpenError(this.name);
}
}
if (this.state === 'half-open' && this.halfOpenCalls >= this.config.halfOpenMaxCalls) {
throw new CircuitOpenError(this.name);
}
try {
if (this.state === 'half-open') {
this.halfOpenCalls++;
}
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.state === 'half-open' || this.failures >= this.config.failureThreshold) {
this.state = 'open';
console.log(`Circuit ${this.name} opened`);
}
}
getState(): CircuitState {
return this.state;
}
}
class CircuitOpenError extends Error {
constructor(circuitName: string) {
super(`Circuit ${circuitName} is open`);
this.name = 'CircuitOpenError';
}
}
Go
type CircuitState int
const (
StateClosed CircuitState = iota
StateOpen
StateHalfOpen
)
type CircuitBreaker struct {
name string
state CircuitState
failures int
lastFailureTime time.Time
halfOpenCalls int
config CircuitBreakerConfig
mu sync.Mutex
}
type CircuitBreakerConfig struct {
FailureThreshold int
ResetTimeout time.Duration
HalfOpenMaxCalls int
}
func NewCircuitBreaker(name string, config CircuitBreakerConfig) *CircuitBreaker {
return &CircuitBreaker{
name: name,
state: StateClosed,
config: config,
}
}
func (cb *CircuitBreaker) Execute(operation func() error) error {
cb.mu.Lock()
if cb.state == StateOpen {
if time.Since(cb.lastFailureTime) >= cb.config.ResetTimeout {
cb.state = StateHalfOpen
cb.halfOpenCalls = 0
} else {
cb.mu.Unlock()
return fmt.Errorf("circuit %s is open", cb.name)
}
}
if cb.state == StateHalfOpen && cb.halfOpenCalls >= cb.config.HalfOpenMaxCalls {
cb.mu.Unlock()
return fmt.Errorf("circuit %s is open", cb.name)
}
if cb.state == StateHalfOpen {
cb.halfOpenCalls++
}
cb.mu.Unlock()
err := operation()
cb.mu.Lock()
defer cb.mu.Unlock()
if err != nil {
cb.onFailure()
return err
}
cb.onSuccess()
return nil
}
func (cb *CircuitBreaker) onSuccess() {
cb.failures = 0
cb.state = StateClosed
}
func (cb *CircuitBreaker) onFailure() {
cb.failures++
cb.lastFailureTime = time.Now()
if cb.state == StateHalfOpen || cb.failures >= cb.config.FailureThreshold {
cb.state = StateOpen
log.Printf("Circuit %s opened", cb.name)
}
}
Timeout en Sagas
Un timeout define cuánto tiempo esperamos máximo por una respuesta. Sin timeouts, una solicitud podría quedar esperando indefinidamente si el servicio destino está colgado.
Los timeouts son críticos en sagas porque:
- No queremos que un paso bloquee toda la saga indefinidamente.
- Nos permiten detectar fallos y comenzar la compensación.
- Liberan recursos (conexiones, memoria) que de otro modo quedarían ocupados.
class SagaWithTimeout {
async executeWithTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number,
stepName: string
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new SagaTimeoutError(stepName, timeoutMs));
}, timeoutMs);
});
return Promise.race([operation(), timeoutPromise]);
}
}
class SagaTimeoutError extends Error {
constructor(stepName: string, timeoutMs: number) {
super(`Step ${stepName} timed out after ${timeoutMs}ms`);
this.name = 'SagaTimeoutError';
}
}
Combinando Resiliencia
En la práctica, los patrones de resiliencia se combinan en capas. El orden típico es:
- Timeout: Limita cuánto tiempo esperar.
- Circuit Breaker: Evita llamar si el servicio está fallando.
- Retry: Si falló, reintenta (a menos que el circuit breaker esté abierto).
Esta combinación proporciona protección en múltiples niveles.
class ResilientSagaStep {
private retryPolicy: RetryPolicy;
private circuitBreaker: CircuitBreaker;
private timeoutMs: number;
constructor(
name: string,
retryConfig: RetryConfig,
circuitConfig: CircuitBreakerConfig,
timeoutMs: number
) {
this.retryPolicy = new RetryPolicy(retryConfig);
this.circuitBreaker = new CircuitBreaker(name, circuitConfig);
this.timeoutMs = timeoutMs;
}
async execute<T>(operation: () => Promise<T>): Promise<T> {
return this.retryPolicy.execute(() =>
this.circuitBreaker.execute(() =>
this.withTimeout(operation)
)
);
}
private async withTimeout<T>(operation: () => Promise<T>): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
return await operation();
} finally {
clearTimeout(timeoutId);
}
}
}
// Uso en saga
const reserveStockStep = new ResilientSagaStep(
'reserveStock',
{ maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 5000, backoffMultiplier: 2, retryableErrors: ['timeout'] },
{ failureThreshold: 5, resetTimeoutMs: 30000, halfOpenMaxCalls: 2 },
5000
);
await reserveStockStep.execute(() => inventoryService.reserve(orderId, items));
Monitoreo de Resiliencia
No basta con implementar patrones de resiliencia; necesitamos observar cómo se están comportando. Las métricas nos dicen si los servicios están saludables, si hay demasiados reintentos, o si los circuit breakers están abriendo con frecuencia.
interface ResilienceMetrics {
circuitState: CircuitState;
totalCalls: number;
successfulCalls: number;
failedCalls: number;
retriedCalls: number;
avgResponseTime: number;
}
class MetricsCollector {
private metrics = new Map<string, ResilienceMetrics>();
record(stepName: string, success: boolean, responseTime: number, retried: boolean): void {
const current = this.metrics.get(stepName) || this.initMetrics();
current.totalCalls++;
if (success) current.successfulCalls++;
else current.failedCalls++;
if (retried) current.retriedCalls++;
current.avgResponseTime = (current.avgResponseTime * (current.totalCalls - 1) + responseTime) / current.totalCalls;
this.metrics.set(stepName, current);
}
private initMetrics(): ResilienceMetrics {
return {
circuitState: 'closed',
totalCalls: 0,
successfulCalls: 0,
failedCalls: 0,
retriedCalls: 0,
avgResponseTime: 0
};
}
}
Resumen
- Reintentos con backoff exponencial para fallos transitorios
- Circuit breaker para evitar cascada de fallos
- Timeouts para no bloquear indefinidamente
- Combinar patrones para máxima resiliencia
- Monitorear métricas de resiliencia
Glosario
Resiliencia
Definición: Capacidad de un sistema para continuar funcionando (posiblemente de forma degradada) ante fallos parciales y recuperarse cuando los problemas se resuelven.
Por qué es importante: En sistemas distribuidos, los fallos son inevitables. Un sistema resiliente puede tolerar fallos de componentes individuales sin caer completamente.
Ejemplo práctico: Si el servicio de notificaciones falla, el sistema sigue procesando pedidos normalmente. Las notificaciones se encolan y se envían cuando el servicio se recupera.
Fallo Transitorio
Definición: Error temporal que probablemente se resolverá por sí solo. Reintentar la operación tiene altas probabilidades de éxito.
Por qué es importante: Distinguir fallos transitorios de permanentes evita reintentos inútiles (en permanentes) y evita fallar prematuramente (en transitorios).
Ejemplo práctico: Un timeout de red, un error 503 (servicio no disponible), o un rechazo por rate limiting son fallos transitorios. Esperar y reintentar generalmente funciona.
Fallo Permanente
Definición: Error que no se resolverá con reintentos. La operación fallará siempre con los mismos datos de entrada.
Por qué es importante: Reintentar fallos permanentes desperdicia recursos y retrasa la detección del error real. Mejor fallar rápido y reportar el problema.
Ejemplo práctico: Un error 400 (datos inválidos), un error 404 (recurso no existe), o una violación de regla de negocio (saldo insuficiente) son permanentes.
Política de Reintentos
Definición: Configuración que define cómo y cuándo reintentar operaciones fallidas: número máximo de intentos, tiempo de espera entre intentos, y qué errores son reintentables.
Por qué es importante: Una política bien configurada balancea entre recuperarse de fallos transitorios y no desperdiciar recursos en fallos permanentes.
Ejemplo práctico: Reintentar máximo 3 veces, con espera inicial de 1 segundo, duplicando la espera cada vez (1s, 2s, 4s), solo para errores de timeout o conexión.
Jitter
Definición: Variación aleatoria añadida al tiempo de espera entre reintentos para evitar que múltiples clientes reintenten simultáneamente.
Por qué es importante: Sin jitter, si 100 clientes fallan al mismo tiempo, todos reintentarán al mismo tiempo, potencialmente sobrecargando el servidor de nuevo.
Ejemplo práctico: En lugar de esperar exactamente 2 segundos, cada cliente espera entre 1.4 y 2.6 segundos (2 segundos +/- 30% aleatorio).
Circuit Breaker
Definición: Patrón que detecta fallos repetidos hacia un servicio y “abre el circuito” para evitar enviar más solicitudes, dando tiempo al servicio para recuperarse.
Por qué es importante: Evita la cascada de fallos donde un servicio caído causa que otros servicios también fallen esperando respuestas que nunca llegan.
Ejemplo práctico: Después de 5 fallos consecutivos al servicio de pagos, el circuit breaker se abre. Las siguientes solicitudes fallan inmediatamente con “CircuitOpen” sin intentar llamar al servicio.
Estado Cerrado (Circuit Breaker)
Definición: Estado normal del circuit breaker donde las solicitudes pasan al servicio destino y se monitorean los resultados.
Por qué es importante: Es el estado de operación normal. El circuit breaker cuenta fallos y éxitos para decidir si debe abrirse.
Ejemplo práctico: El servicio de pagos responde normalmente. Cada solicitud pasa, el contador de fallos se resetea con cada éxito.
Estado Abierto (Circuit Breaker)
Definición: Estado donde el circuit breaker rechaza inmediatamente todas las solicitudes sin intentar contactar al servicio.
Por qué es importante: Protege tanto al cliente (no desperdicia tiempo esperando) como al servidor (no recibe más carga mientras intenta recuperarse).
Ejemplo práctico: El servicio de pagos falló 5 veces seguidas. Durante los próximos 30 segundos, todas las solicitudes de pago fallan inmediatamente con error “Circuit is open”.
Estado Semi-Abierto (Circuit Breaker)
Definición: Estado de transición donde el circuit breaker permite un número limitado de solicitudes de prueba para verificar si el servicio se recuperó.
Por qué es importante: Permite la recuperación automática sin necesidad de intervención manual. Si las pruebas tienen éxito, el circuito se cierra.
Ejemplo práctico: Después de 30 segundos en estado abierto, se permiten 2 solicitudes de prueba. Si ambas tienen éxito, el circuito se cierra. Si alguna falla, vuelve a abrirse.
Timeout
Definición: Tiempo máximo que un sistema espera por una respuesta antes de considerar la operación como fallida.
Por qué es importante: Evita que el sistema quede bloqueado indefinidamente esperando respuestas de servicios que están colgados o inalcanzables.
Ejemplo práctico: Configuramos timeout de 5 segundos para el servicio de pagos. Si no responde en 5 segundos, consideramos que falló y procedemos con la compensación.
Cascada de Fallos
Definición: Situación donde el fallo de un componente causa fallos en otros componentes que dependen de él, propagándose por todo el sistema.
Por qué es importante: Sin protección, un solo servicio caído puede tumbar todo el sistema. Los patrones de resiliencia buscan contener los fallos.
Ejemplo práctico: El servicio de inventario se cae. El servicio de órdenes queda esperando. Esto agota sus conexiones. Ahora el servicio de órdenes también deja de responder, afectando al API gateway, y así sucesivamente.
Métricas de Resiliencia
Definición: Mediciones que indican el comportamiento de los mecanismos de resiliencia: tasa de reintentos, estado de circuit breakers, tiempos de respuesta, etc.
Por qué es importante: Sin métricas, no sabemos si el sistema es resiliente o solo tuvo suerte. Las métricas revelan problemas antes de que causen incidentes graves.
Ejemplo práctico: Monitorear “reintentos por minuto”, “porcentaje de circuit breakers abiertos”, “tiempo promedio de respuesta”, “tasa de timeout”. Alertar si superan umbrales definidos.
Rate Limiting
Definición: Mecanismo que limita el número de solicitudes que un cliente puede hacer en un período de tiempo.
Por qué es importante: Protege al servicio de sobrecarga, ya sea por clientes mal comportados o ataques. Los clientes deben manejar el rechazo (error 429) con reintentos.
Ejemplo práctico: El API permite 100 solicitudes por minuto por cliente. La solicitud 101 recibe error 429 “Too Many Requests”. El cliente debe esperar antes de reintentar.
← Capítulo 4: Transacciones Compensatorias | Capítulo 6: Setup Microservicios →