Un SDK que crashea la aplicación que lo consume es peor que no tener SDK. Si tu librería de observabilidad, de métricas, o de cualquier funcionalidad transversal lanza una excepción no manejada al inicializarse, acabas de convertir un problema de configuración en un outage de producción.
El defensive design en SDKs tiene una regla única: la librería nunca propaga excepciones al caller. Si falla la inicialización, continúa en modo no-op. Si falla una operación, retorna un valor por defecto o un error tipado — nunca un crash.
Este post es la implementación de ese patrón en Python y en C#.
El problema que resuelve
Un SDK se distribuye como dependencia de múltiples servicios. Un bug de inicialización sin defensive design:
Deploy nuevo SDK v1.3.0
→ Servicio A arranca → llama SDK.init() → exception → crash
→ Servicio B arranca → llama SDK.init() → exception → crash
→ Servicio C arranca → llama SDK.init() → exception → crash
Un bug en el SDK de observabilidad hace que todos los servicios que lo usan fallen al arrancar. El SDK que tenía que reportar problemas los causó.
Con defensive design:
Deploy nuevo SDK v1.3.0 con bug en init
→ Servicio A arranca → llama SDK.init() → exception capturada → modo no-op
→ Servicio A funciona SIN observabilidad (degradado, no crash)
→ Log: "sdk.init_failed — running in no-op mode"
El servicio funciona. La observabilidad no. El log indica el problema. Sin outage.
Implementación Python
from typing import Optional
import logging
from opentelemetry.trace import NoOpTracer, Tracer
logger = logging.getLogger(__name__)
class ObservabilitySDK:
"""SDK de observabilidad con defensive design — nunca crashea el caller."""
def __init__(
self,
service_name: str,
connection_string: Optional[str] = None,
):
self._service_name = service_name
self._configured = False
self._init_error: Optional[str] = None
self._tracer_provider = None
try:
self._tracer_provider = self._setup_tracer_provider(
service_name,
connection_string or self._get_connection_from_env(),
)
self._configured = True
logger.info("sdk.initialized", extra={"service": service_name})
except Exception as e:
# NUNCA propagar — capturar y entrar en modo no-op
self._init_error = str(e)
logger.warning(
"sdk.init_failed",
extra={
"service": service_name,
"error": str(e),
"mode": "no-op",
},
exc_info=True,
)
def get_tracer(self, name: str) -> Tracer:
if not self._configured:
return NoOpTracer()
return self._tracer_provider.get_tracer(name)
def is_configured(self) -> bool:
"""Permite al caller verificar si el SDK está activo."""
return self._configured
def health_check(self) -> dict:
"""Endpoint de salud para diagnosticar el estado del SDK."""
return {
"configured": self._configured,
"service": self._service_name,
"error": self._init_error,
}
def _get_connection_from_env(self) -> str:
import os
conn = os.getenv("APPINSIGHTS_CONNECTION_STRING")
if not conn:
raise ValueError("APPINSIGHTS_CONNECTION_STRING not set")
return conn
def _setup_tracer_provider(self, service_name: str, connection_string: str):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
exporter = AzureMonitorTraceExporter(connection_string=connection_string)
provider = TracerProvider(
resource=Resource.create({"service.name": service_name})
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
return provider
El NoOpTracer de OpenTelemetry implementa la misma interfaz que un tracer real — el código del servicio no necesita cambiar si el SDK está en modo no-op.
El health check es obligatorio
El defensive design tiene un trade-off: un servicio puede estar corriendo sin observabilidad sin saberlo. El health check cierra ese gap:
# En el startup de FastAPI
@app.on_event("startup")
async def startup_check():
sdk = ObservabilitySDK(service_name="mi-servicio")
if not sdk.is_configured():
health = sdk.health_check()
logger.warning(
"observability.not_configured",
extra={"health": health}
)
# En algunos sistemas: alertar pero no fallar el startup
# En sistemas críticos: fallar el startup explícitamente
El criterio de cuándo fallar el startup vs continuar degradado depende del sistema. Para observabilidad, continuar degradado es la elección correcta. Para un SDK de pagos o de seguridad, puede ser correcto fallar explícitamente.
Implementación C#: el patrón NullObject
En C#, el patrón Null Object de GoF implementa el defensive design de forma más explícita:
// Interfaz del SDK
public interface IObservabilitySDK
{
ITracer GetTracer(string name);
bool IsConfigured { get; }
HealthCheckResult CheckHealth();
}
// Implementación real
public class AzureMonitorSDK : IObservabilitySDK
{
private readonly TracerProvider? _tracerProvider;
private readonly string? _initError;
public bool IsConfigured { get; private set; }
public AzureMonitorSDK(string serviceName, string? connectionString = null)
{
try
{
var connStr = connectionString
?? Environment.GetEnvironmentVariable("APPINSIGHTS_CONNECTION_STRING")
?? throw new InvalidOperationException("Connection string not configured");
_tracerProvider = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(serviceName))
.AddAzureMonitorTraceExporter(o => o.ConnectionString = connStr)
.Build();
IsConfigured = true;
}
catch (Exception ex)
{
// Nunca propagar al caller
_initError = ex.Message;
IsConfigured = false;
// ILogger en lugar de Console para que sea capturado por el sistema de logs
using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var logger = loggerFactory.CreateLogger<AzureMonitorSDK>();
logger.LogWarning(ex, "SDK init failed — running in no-op mode. Service: {Service}", serviceName);
}
}
public ITracer GetTracer(string name)
{
if (!IsConfigured)
return new NoopTracer();
return TracerProvider.Default.GetTracer(name);
}
public HealthCheckResult CheckHealth() => new()
{
IsHealthy = IsConfigured,
Description = IsConfigured ? "SDK configured" : $"SDK in no-op mode: {_initError}",
};
}
// Implementación Null Object — siempre no-op, sin estado
public class NullObservabilitySDK : IObservabilitySDK
{
public bool IsConfigured => false;
public ITracer GetTracer(string name) => new NoopTracer();
public HealthCheckResult CheckHealth() => new() { IsHealthy = false, Description = "Null SDK" };
}
En el DI container, registrar el SDK con un fallback al Null Object:
// En Program.cs
services.AddSingleton<IObservabilitySDK>(provider =>
{
try
{
return new AzureMonitorSDK(
serviceName: "mi-servicio",
connectionString: configuration["AppInsights:ConnectionString"]
);
}
catch
{
return new NullObservabilitySDK();
}
});
Tests del comportamiento defensivo
El defensive design debe estar testeado — el comportamiento de no-crash es crítico:
# Python
def test_sdk_survives_missing_connection_string(monkeypatch):
"""El SDK no debe lanzar excepción si el connection string no existe."""
monkeypatch.delenv("APPINSIGHTS_CONNECTION_STRING", raising=False)
# No debe lanzar
sdk = ObservabilitySDK(service_name="test-service")
assert not sdk.is_configured()
assert sdk.get_tracer("test") is not None # Retorna NoOpTracer, no None
def test_sdk_survives_invalid_connection_string():
"""El SDK no debe crashear con un connection string inválido."""
sdk = ObservabilitySDK(
service_name="test-service",
connection_string="InstrumentationKey=invalid-key",
)
assert not sdk.is_configured()
health = sdk.health_check()
assert health["configured"] == False
assert health["error"] is not None
def test_tracer_works_in_noop_mode():
"""El tracer en modo no-op debe ser usable."""
sdk = ObservabilitySDK(service_name="test", connection_string=None)
tracer = sdk.get_tracer("test-module")
# Debe poder usarse sin excepción
with tracer.start_as_current_span("test-span"):
pass # No debe lanzar
Lo que aprendí
El modo no-op no es un fallback de segunda clase. Es el contrato correcto para dependencias transversales. El servicio debe funcionar con o sin observabilidad. Si la observabilidad es crítica para el funcionamiento del servicio, tiene que ser una dependencia dura, no suave.
El health check es la compensación obligatoria. El defensive design silencia los errores de inicialización. Sin health check, el servicio puede correr días en modo no-op sin que nadie se dé cuenta. El health check endpoint + alerta si is_configured == false es la compensación.
Testear el comportamiento defensivo como primer test, no como afterthought. El primer test que escribo para un SDK es “no crashea cuando falta la configuración”. Ese test es más importante que cualquier test de funcionalidad — protege contra la regresión más catastrófica.
El patrón Null Object en C# vs try/except en Python. Ambos logran el mismo resultado. El Null Object es más explícito (hay una clase separada para el modo no-op) y más fácil de mockear en tests. En Python, el try/except en __init__ es más idiomático.