Módulo 6: Cloud Migration Patterns
7. Graceful Degradation
Descripción
En esta cápsula vas a diseñar tu aplicación para que degrade funcionalidad en lugar de crashear cuando un servicio cloud no responde. S3 caído no significa app muerta — significa app con funcionalidad reducida. Un endpoint de SageMaker lento no significa timeout para el usuario — significa que la app usa un fallback. Vas a implementar circuit breakers, fallback patterns, retry con backoff, y un health check con niveles de degradación que comunica exactamente qué funciona y qué no.
Contexto: En la cápsula anterior, feature flags te permiten habilitar/deshabilitar features por entorno. Pero hay un escenario que feature flags no cubren: una feature está habilitada, el servicio existe, pero falla en runtime. S3 puede tener un blip de 30 segundos. SageMaker puede estar escalando. Lambda puede estar en cold start. Si tu app crashea en esos momentos, pierdes requests, usuarios, y confianza. Graceful degradation es el patrón que convierte "app muerta por 30 segundos" en "app con funcionalidad reducida por 30 segundos."
El Problema: Servicios Cloud Fallan
Por qué tu app no puede asumir que todo funciona siempre
Realidad de servicios cloud:
S3: 99.99% SLA = ~52 minutos de downtime/año
→ Tu app recibe ~52 minutos de errores S3 por año
Lambda: 99.95% SLA = ~4.4 horas de downtime/año
→ Incluye cold starts, throttling, timeouts
SageMaker: Endpoints escalan bajo demanda
→ Latencia pasa de 100ms a 5000ms durante escalado
Network: Blips de 1-30 segundos
→ Timeouts intermitentes que no son "caída" sino transitorios
LocalStack: No garantiza SLA
→ En desarrollo, puede reiniciarse, quedarse sin memoria
El anti-patrón: asumir disponibilidad al 100%
# ❌ Anti-patrón — si S3 falla, toda la app crashea
def process_request(document: dict) -> dict:
template = s3.get_object(Bucket=bucket, Key="prompts/v1/system.txt")
# Si S3 no responde → ConnectionError → 500 Internal Server Error
# El usuario ve un error críptico
# No hay retry, no hay fallback, no hay degradación
El patrón: la app decide qué hacer cuando algo falla
# ✅ Patrón — si S3 falla, la app degrada
def process_request(document: dict) -> dict:
try:
template = get_prompt_with_fallback("summarizer", "v1")
except ServiceUnavailableError:
return {
"status": "degraded",
"message": "Servicio procesando con capacidad reducida",
"result": process_with_default_template(document),
}
Patrón 1: Retry con Exponential Backoff
La primera línea de defensa
Muchas fallas son transitorias: un blip de red de 500ms, un throttle de S3 por rate limit, un cold start de Lambda. Un retry simple resuelve la mayoría de estos casos.
"""services/retry.py — Retry con exponential backoff."""
import time
import logging
from typing import TypeVar, Callable
from functools import wraps
logger = logging.getLogger(__name__)
T = TypeVar("T")
class RetryExhaustedError(Exception):
"""Todos los reintentos fallaron."""
def __init__(self, operation: str, attempts: int, last_error: Exception):
self.operation = operation
self.attempts = attempts
self.last_error = last_error
super().__init__(
f"{operation} falló tras {attempts} intentos: {last_error}"
)
def retry_with_backoff(
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
retryable_exceptions: tuple = (Exception,),
):
"""Decorator que agrega retry con exponential backoff."""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(1, max_retries + 1):
try:
result = func(*args, **kwargs)
if attempt > 1:
logger.info(
f"{func.__name__} exitoso en intento {attempt}"
)
return result
except retryable_exceptions as e:
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
logger.warning(
f"{func.__name__} falló (intento {attempt}/{max_retries}): "
f"{e}. Reintentando en {delay:.1f}s"
)
time.sleep(delay)
raise RetryExhaustedError(
operation=func.__name__,
attempts=max_retries,
last_error=last_exception,
)
return wrapper
return decorator
Uso del retry
from botocore.exceptions import ClientError, EndpointConnectionError
@retry_with_backoff(
max_retries=3,
base_delay=1.0,
retryable_exceptions=(ClientError, EndpointConnectionError, ConnectionError),
)
def get_prompt_template(s3_client, bucket: str, name: str, version: str) -> str:
key = f"prompts/{name}/{version}/system.txt"
response = s3_client.get_object(Bucket=bucket, Key=key)
return response["Body"].read().decode("utf-8")
Patrón 2: Circuit Breaker
Evitar reintentos cuando el servicio está claramente caído
Si S3 ha fallado 10 veces en los últimos 30 segundos, seguir reintentando solo agrega latencia. Un circuit breaker "abre el circuito" después de N fallos y retorna el fallback inmediatamente hasta que el servicio se recupere.
"""services/circuit_breaker.py — Circuit breaker para servicios cloud."""
import time
import logging
from enum import Enum
from typing import Any, Callable
logger = logging.getLogger(__name__)
class CircuitState(str, Enum):
CLOSED = "closed" # Normal: requests pasan
OPEN = "open" # Abierto: requests van a fallback directo
HALF_OPEN = "half_open" # Probando: deja pasar 1 request para verificar
class CircuitBreakerError(Exception):
"""Circuit breaker abierto — servicio no disponible."""
def __init__(self, service: str, failures: int, reset_in: float):
self.service = service
self.failures = failures
self.reset_in = reset_in
super().__init__(
f"Circuit open para '{service}': {failures} fallos, "
f"reset en {reset_in:.0f}s"
)
class CircuitBreaker:
"""Circuit breaker para un servicio cloud.
Estados:
- CLOSED: operación normal, requests pasan al servicio
- OPEN: servicio caído, requests van a fallback inmediatamente
- HALF_OPEN: deja pasar 1 request para probar si el servicio se recuperó
"""
def __init__(
self,
service_name: str,
failure_threshold: int = 5,
reset_timeout: float = 60.0,
half_open_max_calls: int = 1,
):
self.service_name = service_name
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.half_open_max_calls = half_open_max_calls
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._last_failure_time: float = 0
self._half_open_calls = 0
@property
def state(self) -> CircuitState:
if self._state == CircuitState.OPEN:
elapsed = time.time() - self._last_failure_time
if elapsed >= self.reset_timeout:
self._state = CircuitState.HALF_OPEN
self._half_open_calls = 0
logger.info(
f"[{self.service_name}] Circuit → HALF_OPEN "
f"(probando recuperación)"
)
return self._state
def execute(self, func: Callable, *args, **kwargs) -> Any:
"""Ejecuta la función a través del circuit breaker."""
current_state = self.state
if current_state == CircuitState.OPEN:
raise CircuitBreakerError(
service=self.service_name,
failures=self._failure_count,
reset_in=self.reset_timeout - (time.time() - self._last_failure_time),
)
if current_state == CircuitState.HALF_OPEN:
if self._half_open_calls >= self.half_open_max_calls:
raise CircuitBreakerError(
service=self.service_name,
failures=self._failure_count,
reset_in=self.reset_timeout,
)
self._half_open_calls += 1
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure(e)
raise
def _on_success(self):
if self._state == CircuitState.HALF_OPEN:
logger.info(
f"[{self.service_name}] Circuit → CLOSED (servicio recuperado)"
)
self._failure_count = 0
self._state = CircuitState.CLOSED
self._success_count += 1
def _on_failure(self, error: Exception):
self._failure_count += 1
self._last_failure_time = time.time()
if self._failure_count >= self.failure_threshold:
self._state = CircuitState.OPEN
logger.error(
f"[{self.service_name}] Circuit → OPEN "
f"({self._failure_count} fallos): {error}"
)
def status(self) -> dict:
return {
"service": self.service_name,
"state": self.state.value,
"failure_count": self._failure_count,
"success_count": self._success_count,
"threshold": self.failure_threshold,
"reset_timeout": self.reset_timeout,
}
Patrón 3: Fallback Strategies
Estrategias de fallback para diferentes servicios
"""services/fallbacks.py — Estrategias de fallback para servicios cloud."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
class FallbackStrategy:
"""Estrategias de fallback para cuando un servicio no está disponible."""
def __init__(self, s3_client: Any = None, bucket: str = ""):
self.s3 = s3_client
self.bucket = bucket
self._cache: dict[str, Any] = {}
def get_prompt_with_fallback(
self, s3_client, bucket: str, name: str, version: str
) -> str:
"""Obtiene prompt template con cadena de fallbacks.
1. S3 (fuente primaria)
2. Cache en memoria (si se leyó antes)
3. Default hardcodeado (último recurso)
"""
cache_key = f"prompts/{name}/{version}"
# Intento 1: S3
try:
key = f"prompts/{name}/{version}/system.txt"
response = s3_client.get_object(Bucket=bucket, Key=key)
content = response["Body"].read().decode("utf-8")
self._cache[cache_key] = content
return content
except Exception as e:
logger.warning(f"S3 fallback activado para {cache_key}: {e}")
# Intento 2: cache en memoria
if cache_key in self._cache:
logger.info(f"Usando prompt cacheado para {cache_key}")
return self._cache[cache_key]
# Intento 3: default
logger.warning(f"Usando prompt default para {name}")
return self._get_default_prompt(name)
def _get_default_prompt(self, name: str) -> str:
"""Prompts default hardcodeados como último recurso."""
defaults = {
"summarizer": (
"Genera un resumen conciso del siguiente texto. "
"Máximo 3 párrafos."
),
"classifier": (
"Clasifica el siguiente texto en una de estas categorías: "
"técnico, negocio, general."
),
}
return defaults.get(name, "Procesa el siguiente texto.")
def invoke_with_fallback(
self,
primary_func,
fallback_func,
*args,
**kwargs,
) -> dict:
"""Ejecuta primary_func; si falla, ejecuta fallback_func."""
try:
result = primary_func(*args, **kwargs)
result["_degraded"] = False
return result
except Exception as e:
logger.warning(f"Primary function failed: {e}. Using fallback.")
result = fallback_func(*args, **kwargs)
result["_degraded"] = True
result["_degradation_reason"] = str(e)
return result
Patrón 4: Health Check con Niveles de Degradación
Health check que comunica el estado exacto
"""services/health.py — Health check con niveles de degradación."""
import time
import logging
from enum import Enum
from typing import Any
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
class HealthLevel(str, Enum):
HEALTHY = "healthy" # Todo funciona
DEGRADED = "degraded" # Funcionalidad reducida
CRITICAL = "critical" # Funcionalidad mínima
UNAVAILABLE = "unavailable" # App no puede operar
@dataclass
class ServiceHealth:
name: str
status: str
latency_ms: float = 0
error: str | None = None
critical: bool = False
@dataclass
class AppHealth:
level: HealthLevel
services: list[ServiceHealth] = field(default_factory=list)
message: str = ""
degraded_features: list[str] = field(default_factory=list)
available_features: list[str] = field(default_factory=list)
class HealthChecker:
"""Verifica la salud de la app con niveles de degradación."""
def __init__(self, factory, settings, feature_flags):
self.factory = factory
self.settings = settings
self.flags = feature_flags
def check(self) -> AppHealth:
"""Ejecuta health check completo."""
services = []
# S3 — crítico (sin S3 no hay prompts ni documentos)
s3_health = self._check_s3()
services.append(s3_health)
# Lambda — crítico (sin Lambda no hay inferencia)
lambda_health = self._check_lambda()
services.append(lambda_health)
# SageMaker — opcional (si feature flag activo)
if self.flags.is_enabled("sagemaker_enrichment"):
sm_health = self._check_sagemaker()
services.append(sm_health)
# CloudWatch — opcional
if self.flags.is_enabled("cloudwatch_metrics"):
cw_health = self._check_cloudwatch()
services.append(cw_health)
return self._compute_overall(services)
def _check_s3(self) -> ServiceHealth:
start = time.time()
try:
s3 = self.factory.s3
s3.head_bucket(Bucket=self.settings.s3_bucket)
latency = (time.time() - start) * 1000
return ServiceHealth(
name="s3", status="healthy", latency_ms=latency, critical=True
)
except Exception as e:
latency = (time.time() - start) * 1000
return ServiceHealth(
name="s3", status="unhealthy", latency_ms=latency,
error=str(e), critical=True
)
def _check_lambda(self) -> ServiceHealth:
start = time.time()
try:
lam = self.factory.lambda_client
lam.list_functions(MaxItems=1)
latency = (time.time() - start) * 1000
return ServiceHealth(
name="lambda", status="healthy", latency_ms=latency, critical=True
)
except Exception as e:
latency = (time.time() - start) * 1000
return ServiceHealth(
name="lambda", status="unhealthy", latency_ms=latency,
error=str(e), critical=True
)
def _check_sagemaker(self) -> ServiceHealth:
start = time.time()
try:
sm = self.factory.get_client("sagemaker")
sm.list_endpoints(MaxResults=1)
latency = (time.time() - start) * 1000
return ServiceHealth(
name="sagemaker", status="healthy", latency_ms=latency
)
except Exception as e:
latency = (time.time() - start) * 1000
return ServiceHealth(
name="sagemaker", status="unhealthy", latency_ms=latency,
error=str(e)
)
def _check_cloudwatch(self) -> ServiceHealth:
start = time.time()
try:
cw = self.factory.get_client("cloudwatch")
cw.list_metrics(Namespace="AIService", Limit=1)
latency = (time.time() - start) * 1000
return ServiceHealth(
name="cloudwatch", status="healthy", latency_ms=latency
)
except Exception as e:
latency = (time.time() - start) * 1000
return ServiceHealth(
name="cloudwatch", status="unhealthy", latency_ms=latency,
error=str(e)
)
def _compute_overall(self, services: list[ServiceHealth]) -> AppHealth:
"""Computa el nivel de salud general basado en los servicios."""
critical_down = [s for s in services if s.critical and s.status == "unhealthy"]
optional_down = [s for s in services if not s.critical and s.status == "unhealthy"]
all_healthy = all(s.status == "healthy" for s in services)
degraded_features = []
available_features = []
if all_healthy:
level = HealthLevel.HEALTHY
message = "Todos los servicios operando normalmente"
available_features = [s.name for s in services]
elif critical_down:
if len(critical_down) == len([s for s in services if s.critical]):
level = HealthLevel.UNAVAILABLE
message = f"Servicios críticos no disponibles: {[s.name for s in critical_down]}"
else:
level = HealthLevel.CRITICAL
message = f"Algunos servicios críticos degradados: {[s.name for s in critical_down]}"
degraded_features = [s.name for s in critical_down + optional_down]
available_features = [s.name for s in services if s.status == "healthy"]
elif optional_down:
level = HealthLevel.DEGRADED
message = f"Servicios opcionales no disponibles: {[s.name for s in optional_down]}"
degraded_features = [s.name for s in optional_down]
available_features = [s.name for s in services if s.status == "healthy"]
else:
level = HealthLevel.HEALTHY
message = "OK"
available_features = [s.name for s in services]
return AppHealth(
level=level,
services=services,
message=message,
degraded_features=degraded_features,
available_features=available_features,
)
Health endpoint con degradation levels
"""handler.py — Health endpoint que reporta niveles de degradación."""
import json
from services.health import HealthChecker, HealthLevel
def health_endpoint(checker: HealthChecker) -> dict:
"""Endpoint /health con información de degradación."""
health = checker.check()
status_codes = {
HealthLevel.HEALTHY: 200,
HealthLevel.DEGRADED: 200,
HealthLevel.CRITICAL: 503,
HealthLevel.UNAVAILABLE: 503,
}
body = {
"status": health.level.value,
"message": health.message,
"available_features": health.available_features,
"degraded_features": health.degraded_features,
"services": [
{
"name": s.name,
"status": s.status,
"latency_ms": round(s.latency_ms, 1),
"critical": s.critical,
"error": s.error,
}
for s in health.services
],
}
return {
"statusCode": status_codes.get(health.level, 500),
"body": json.dumps(body),
}
Ejemplo de response degradado:
{
"status": "degraded",
"message": "Servicios opcionales no disponibles: ['sagemaker']",
"available_features": ["s3", "lambda", "cloudwatch"],
"degraded_features": ["sagemaker"],
"services": [
{"name": "s3", "status": "healthy", "latency_ms": 12.3, "critical": true},
{"name": "lambda", "status": "healthy", "latency_ms": 45.1, "critical": true},
{"name": "sagemaker", "status": "unhealthy", "latency_ms": 5001.2, "critical": false,
"error": "Endpoint scaling in progress"},
{"name": "cloudwatch", "status": "healthy", "latency_ms": 23.4, "critical": false}
]
}
Integrando Todo: Processor con Graceful Degradation
"""services/resilient_processor.py — Processor con degradación completa."""
import json
import logging
from typing import Any
from services.circuit_breaker import CircuitBreaker, CircuitBreakerError
from services.retry import retry_with_backoff, RetryExhaustedError
from services.fallbacks import FallbackStrategy
logger = logging.getLogger(__name__)
class ResilientDocumentProcessor:
"""Processor que degrada gracefully cuando servicios fallan."""
def __init__(
self,
s3_client: Any,
bucket: str,
feature_flags,
sagemaker_client: Any = None,
):
self.s3 = s3_client
self.bucket = bucket
self.flags = feature_flags
self.sagemaker = sagemaker_client
self.s3_breaker = CircuitBreaker("s3", failure_threshold=5, reset_timeout=30)
self.sm_breaker = CircuitBreaker("sagemaker", failure_threshold=3, reset_timeout=60)
self.fallbacks = FallbackStrategy()
def process(self, prompt_name: str, prompt_version: str, document: dict) -> dict:
"""Procesa documento con degradación en cada paso."""
result = {"degraded": False, "degradation_details": []}
# Paso 1: obtener prompt (con fallback)
try:
template = self.s3_breaker.execute(
self._get_prompt, prompt_name, prompt_version
)
except (CircuitBreakerError, RetryExhaustedError) as e:
template = self.fallbacks._get_default_prompt(prompt_name)
result["degraded"] = True
result["degradation_details"].append(f"Prompt: usando default ({e})")
# Paso 2: almacenar documento (mejor esfuerzo)
doc_key = None
try:
doc_key = self.s3_breaker.execute(
self._store_document, document
)
except (CircuitBreakerError, RetryExhaustedError) as e:
result["degraded"] = True
result["degradation_details"].append(f"Storage: documento no persistido ({e})")
# Paso 3: enriquecimiento SageMaker (opcional)
enrichment = None
if self.flags.is_enabled("sagemaker_enrichment") and self.sagemaker:
try:
enrichment = self.sm_breaker.execute(
self._enrich_sagemaker, document
)
except (CircuitBreakerError, RetryExhaustedError) as e:
result["degradation_details"].append(
f"SageMaker: enriquecimiento no disponible ({e})"
)
result.update({
"prompt_used": f"{prompt_name}/{prompt_version}",
"template_preview": template[:80],
"document_stored": doc_key,
"sagemaker_enrichment": enrichment,
"circuit_breakers": {
"s3": self.s3_breaker.status(),
"sagemaker": self.sm_breaker.status(),
},
})
return result
@retry_with_backoff(max_retries=2, base_delay=0.5)
def _get_prompt(self, name: str, version: str) -> str:
key = f"prompts/{name}/{version}/system.txt"
response = self.s3.get_object(Bucket=self.bucket, Key=key)
return response["Body"].read().decode("utf-8")
@retry_with_backoff(max_retries=2, base_delay=0.5)
def _store_document(self, document: dict) -> str:
doc_id = document.get("id", "unknown")
key = f"documents/inbox/{doc_id}.json"
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(document).encode("utf-8"),
)
return key
@retry_with_backoff(max_retries=1, base_delay=1.0)
def _enrich_sagemaker(self, document: dict) -> dict:
response = self.sagemaker.invoke_endpoint(
EndpointName="document-enrichment",
ContentType="application/json",
Body=json.dumps(document).encode("utf-8"),
)
return json.loads(response["Body"].read().decode("utf-8"))
Troubleshooting
Problema 1: Circuit breaker abre demasiado rápido
5 errores en ráfaga abren el circuit, pero los errores eran transitorios.
# Solución: aumentar threshold o agregar ventana de tiempo
# En lugar de "5 errores totales" → "5 errores en los últimos 30 segundos"
class WindowedCircuitBreaker(CircuitBreaker):
def __init__(self, *args, window_seconds: float = 30.0, **kwargs):
super().__init__(*args, **kwargs)
self._failure_times: list[float] = []
self._window = window_seconds
def _on_failure(self, error):
now = time.time()
self._failure_times.append(now)
# Solo contar fallos dentro de la ventana
self._failure_times = [
t for t in self._failure_times
if now - t < self._window
]
self._failure_count = len(self._failure_times)
# ... resto de la lógica
Problema 2: Fallback retorna datos obsoletos del cache
El cache en memoria no se invalida y el template cambió en S3.
# Solución: cache con TTL
import time
class TTLCache:
def __init__(self, ttl_seconds: int = 300):
self._cache: dict[str, tuple[Any, float]] = {}
self._ttl = ttl_seconds
def get(self, key: str) -> Any | None:
if key in self._cache:
value, timestamp = self._cache[key]
if time.time() - timestamp < self._ttl:
return value
del self._cache[key]
return None
def set(self, key: str, value: Any):
self._cache[key] = (value, time.time())
Problema 3: Health check timeout cuando un servicio es lento
SageMaker endpoint tardando 10s hace que el health check sea lento.
# Solución: timeout por servicio en health check
import signal
class TimeoutError(Exception):
pass
def with_timeout(func, timeout_seconds=5):
def handler(signum, frame):
raise TimeoutError(f"Timeout after {timeout_seconds}s")
signal.signal(signal.SIGALRM, handler)
signal.alarm(timeout_seconds)
try:
return func()
finally:
signal.alarm(0)
Problema 4: Retry y circuit breaker interfieren
Retry reintenta 3 veces, cada intento cuenta como fallo en el circuit breaker.
# Solución: solo contar como fallo después de agotar retries
# El circuit breaker envuelve la función con retry:
# circuit_breaker.execute(retry_function)
# NO: retry(circuit_breaker.execute(function))
# Correcto:
result = self.s3_breaker.execute(self._get_prompt_with_retry, name, version)
# Donde _get_prompt_with_retry ya tiene @retry_with_backoff
Ejercicios Prácticos
Ejercicio 1: Cache con fallback multi-nivel
Implementa un cache con tres niveles: memoria (L1), archivo local (L2), y S3 (L3). Si L1 falla, prueba L2. Si L2 falla, prueba L3. Cada nivel cachea lo que obtiene del nivel inferior.
Ver solución
import json
import os
from typing import Any, Optional
class MultiLevelCache:
"""Cache con 3 niveles: memoria → archivo → S3."""
def __init__(self, s3_client=None, bucket: str = "", cache_dir: str = ".cache"):
self._memory: dict[str, Any] = {}
self._cache_dir = cache_dir
self._s3 = s3_client
self._bucket = bucket
os.makedirs(cache_dir, exist_ok=True)
def get(self, key: str) -> Optional[Any]:
# L1: memoria
if key in self._memory:
return self._memory[key]
# L2: archivo local
file_path = os.path.join(self._cache_dir, key.replace("/", "_"))
if os.path.exists(file_path):
with open(file_path) as f:
value = json.load(f)
self._memory[key] = value # Promote to L1
return value
# L3: S3
if self._s3 and self._bucket:
try:
response = self._s3.get_object(
Bucket=self._bucket, Key=f"cache/{key}"
)
value = json.loads(response["Body"].read().decode("utf-8"))
self._memory[key] = value # Promote to L1
self._save_to_file(key, value) # Promote to L2
return value
except Exception:
pass
return None
def set(self, key: str, value: Any):
# Write to all levels
self._memory[key] = value
self._save_to_file(key, value)
if self._s3 and self._bucket:
try:
self._s3.put_object(
Bucket=self._bucket,
Key=f"cache/{key}",
Body=json.dumps(value).encode("utf-8"),
)
except Exception:
pass
def _save_to_file(self, key: str, value: Any):
file_path = os.path.join(self._cache_dir, key.replace("/", "_"))
with open(file_path, "w") as f:
json.dump(value, f)
def stats(self) -> dict:
file_count = len(os.listdir(self._cache_dir))
return {
"l1_memory": len(self._memory),
"l2_files": file_count,
"l3_s3": "connected" if self._s3 else "disabled",
}
cache = MultiLevelCache()
cache.set("prompts/summarizer/v1", {"template": "Resume en 3 puntos"})
result = cache.get("prompts/summarizer/v1")
print(f"Resultado: {result}")
print(f"Stats: {cache.stats()}")
Ejercicio 2: Circuit breaker con métricas
Extiende el CircuitBreaker para que registre métricas: número total de calls, success rate, tiempos de respuesta promedio, y número de veces que abrió el circuito.
Ver solución
import time
from collections import deque
class MetricCircuitBreaker:
"""Circuit breaker con métricas detalladas."""
def __init__(self, service_name: str, failure_threshold: int = 5,
reset_timeout: float = 60.0):
self.service_name = service_name
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self._failure_count = 0
self._state = "closed"
self._last_failure_time = 0.0
self._total_calls = 0
self._total_successes = 0
self._total_failures = 0
self._total_circuit_opens = 0
self._response_times: deque = deque(maxlen=100)
def execute(self, func, *args, **kwargs):
if self._state == "open":
elapsed = time.time() - self._last_failure_time
if elapsed < self.reset_timeout:
self._total_calls += 1
raise RuntimeError(f"Circuit open for {self.service_name}")
self._state = "half_open"
self._total_calls += 1
start = time.time()
try:
result = func(*args, **kwargs)
elapsed_ms = (time.time() - start) * 1000
self._response_times.append(elapsed_ms)
self._total_successes += 1
self._failure_count = 0
self._state = "closed"
return result
except Exception as e:
elapsed_ms = (time.time() - start) * 1000
self._response_times.append(elapsed_ms)
self._total_failures += 1
self._failure_count += 1
self._last_failure_time = time.time()
if self._failure_count >= self.failure_threshold:
self._state = "open"
self._total_circuit_opens += 1
raise
def metrics(self) -> dict:
avg_response = (
sum(self._response_times) / len(self._response_times)
if self._response_times else 0
)
success_rate = (
self._total_successes / self._total_calls * 100
if self._total_calls > 0 else 0
)
return {
"service": self.service_name,
"state": self._state,
"total_calls": self._total_calls,
"successes": self._total_successes,
"failures": self._total_failures,
"success_rate_pct": round(success_rate, 1),
"avg_response_ms": round(avg_response, 1),
"circuit_opens": self._total_circuit_opens,
"current_failures": self._failure_count,
}
# Demo
cb = MetricCircuitBreaker("s3", failure_threshold=3)
for i in range(10):
try:
cb.execute(lambda: "ok" if i % 4 != 0 else (_ for _ in ()).throw(Exception("fail")))
except Exception:
pass
print(json.dumps(cb.metrics(), indent=2))
Ejercicio 3: Degradation response builder
Crea un builder que construya respuestas HTTP apropiadas según el nivel de degradación: 200 con resultado completo, 200 con resultado parcial y header de degradación, o 503 con mensaje de servicio no disponible.
Ver solución
import json
from dataclasses import dataclass, field
@dataclass
class DegradationInfo:
level: str # "none", "partial", "severe", "unavailable"
affected_features: list[str] = field(default_factory=list)
fallbacks_used: list[str] = field(default_factory=list)
class DegradedResponseBuilder:
"""Construye responses HTTP apropiados según el nivel de degradación."""
def build(self, result: dict, degradation: DegradationInfo) -> dict:
if degradation.level == "none":
return self._healthy_response(result)
elif degradation.level == "partial":
return self._partial_response(result, degradation)
elif degradation.level == "severe":
return self._severe_response(result, degradation)
else:
return self._unavailable_response(degradation)
def _healthy_response(self, result: dict) -> dict:
return {
"statusCode": 200,
"headers": {"X-Degradation": "none"},
"body": json.dumps({"status": "ok", "data": result}),
}
def _partial_response(self, result: dict, deg: DegradationInfo) -> dict:
return {
"statusCode": 200,
"headers": {
"X-Degradation": "partial",
"X-Degraded-Features": ",".join(deg.affected_features),
},
"body": json.dumps({
"status": "partial",
"data": result,
"degradation": {
"level": "partial",
"message": "Algunos features no disponibles",
"affected": deg.affected_features,
"fallbacks": deg.fallbacks_used,
},
}),
}
def _severe_response(self, result: dict, deg: DegradationInfo) -> dict:
return {
"statusCode": 200,
"headers": {"X-Degradation": "severe"},
"body": json.dumps({
"status": "degraded",
"data": result,
"degradation": {
"level": "severe",
"message": "Funcionalidad significativamente reducida",
"affected": deg.affected_features,
"fallbacks": deg.fallbacks_used,
},
}),
}
def _unavailable_response(self, deg: DegradationInfo) -> dict:
return {
"statusCode": 503,
"headers": {
"X-Degradation": "unavailable",
"Retry-After": "30",
},
"body": json.dumps({
"status": "unavailable",
"message": "Servicio temporalmente no disponible",
"affected": deg.affected_features,
}),
}
# Demo
builder = DegradedResponseBuilder()
# Respuesta saludable
resp = builder.build({"answer": "42"}, DegradationInfo(level="none"))
print(f"Healthy: {resp['statusCode']}")
# Respuesta parcial
resp = builder.build(
{"answer": "42"},
DegradationInfo(
level="partial",
affected_features=["sagemaker"],
fallbacks_used=["default_enrichment"],
),
)
print(f"Partial: {resp['statusCode']}, headers: {resp['headers']}")
# Unavailable
resp = builder.build(
{},
DegradationInfo(level="unavailable", affected_features=["s3", "lambda"]),
)
print(f"Unavailable: {resp['statusCode']}")
Ejercicio 4: Degradation simulation test
Escribe un test que simule la caída de S3 y verifique que el ResilientDocumentProcessor retorna una respuesta degradada en lugar de crashear.
Ver solución
from unittest.mock import MagicMock, patch
from botocore.exceptions import ClientError
from services.feature_flags import FeatureFlags
from config.settings import Settings
def test_processor_degrades_when_s3_fails():
"""Verifica que el processor degrada cuando S3 no responde."""
mock_s3 = MagicMock()
mock_s3.get_object.side_effect = ClientError(
{"Error": {"Code": "500", "Message": "Internal Server Error"}},
"GetObject",
)
mock_s3.put_object.side_effect = ClientError(
{"Error": {"Code": "500", "Message": "Internal Server Error"}},
"PutObject",
)
settings = Settings(environment="local")
flags = FeatureFlags(settings)
from services.resilient_processor import ResilientDocumentProcessor
processor = ResilientDocumentProcessor(
s3_client=mock_s3,
bucket="test-bucket",
feature_flags=flags,
)
result = processor.process(
prompt_name="summarizer",
prompt_version="v1",
document={"id": "test-doc", "content": "Test content"},
)
assert result["degraded"] is True
assert len(result["degradation_details"]) > 0
assert "Prompt: usando default" in result["degradation_details"][0]
assert result["template_preview"] is not None
print(f"✅ Processor degradó correctamente: {result['degradation_details']}")
def test_processor_recovers_after_circuit_reset():
"""Verifica que el processor se recupera cuando S3 vuelve."""
call_count = 0
fail_until = 3
def conditional_get(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count <= fail_until:
raise ClientError(
{"Error": {"Code": "500", "Message": "Temporary failure"}},
"GetObject",
)
body = MagicMock()
body.read.return_value = b"Recovered prompt template"
return {"Body": body}
mock_s3 = MagicMock()
mock_s3.get_object.side_effect = conditional_get
mock_s3.put_object.return_value = {}
settings = Settings(environment="local")
flags = FeatureFlags(settings)
from services.resilient_processor import ResilientDocumentProcessor
processor = ResilientDocumentProcessor(
s3_client=mock_s3,
bucket="test-bucket",
feature_flags=flags,
)
# Forzar reset inmediato del circuit breaker para test
processor.s3_breaker.reset_timeout = 0.1
import time
time.sleep(0.2) # Esperar reset
call_count = fail_until # Ya pasaron los fallos
result = processor.process("summarizer", "v1", {"id": "test"})
print(f"✅ Recovery result: degraded={result['degraded']}")
test_processor_degrades_when_s3_fails()
Resumen
- Graceful degradation convierte "app muerta" en "app con funcionalidad reducida." S3 caído no significa error 500 — significa que la app usa prompts default del cache.
- Retry con backoff es la primera línea de defensa. La mayoría de fallas cloud son transitorias (500ms-5s). Un retry las resuelve sin que el usuario note nada.
- Circuit breaker previene cascadas de fallo. Si S3 está caído, seguir reintentando solo agrega latencia. El circuit breaker retorna fallback inmediatamente.
- El health check comunica el estado exacto. HEALTHY → DEGRADED → CRITICAL → UNAVAILABLE. Cada nivel dice qué funciona y qué no.
- Fallback strategies definen qué hacer cuando algo falla. S3 → cache en memoria → default hardcodeado. Cada nivel es peor, pero ninguno es un crash.
- En la siguiente cápsula, integras todo en una Migration-Ready AI App — el proyecto del módulo.
Recursos Adicionales
- Microsoft — Circuit Breaker Pattern — Referencia del patrón
- AWS Well-Architected — Reliability — Principios de reliability
- Martin Fowler — Circuit Breaker — Artículo original
- Retry Pattern — Azure — Patrón retry con backoff
- Hystrix Wiki — Netflix Hystrix (circuit breaker reference)
- Graceful Degradation vs Progressive Enhancement — Concepto general