patternssdkpythoncsharpdefensive-designlibrary-designarchitecture

El patrón que evita que tu SDK crashee la app que lo consume

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.

Volver al blog