SRE: Gestión de Dependencias y Degradación Elegante

2026-03-17 | Gabriel Garrido | 26 min de lectura
Share:

Apoya este blog

Si te resulta util este contenido, considera apoyar el blog.

Introducción

En los artículos anteriores cubrimos SLIs y SLOs, gestión de incidentes, observabilidad, ingeniería del caos, planificación de capacidad, GitOps, gestión de secretos y optimización de costos. Todos esos se enfocan en tus propios sistemas, tu propio código, tu propia infraestructura. Pero la cosa es así: tu servicio no existe de forma aislada.


Cada llamada HTTP a otro servicio, cada consulta a la base de datos, cada mensaje publicado a una cola, cada integración con una API de terceros es una dependencia. Y cada dependencia es un punto potencial de falla. Cuando ese gateway de pagos se cae a las 2 de la mañana o ese servicio interno de autenticación empieza a devolver 500s bajo carga, ¿qué le pasa a tu servicio? ¿Se cae? ¿Se cuelga? ¿O maneja la situación de forma elegante y sigue atendiendo usuarios con funcionalidad reducida?


En este artículo vamos a cubrir cómo pensar en las dependencias como riesgo, implementar circuit breakers, aplicar el patrón bulkhead, manejar timeouts y reintentos correctamente, construir estrategias de fallback, configurar health checks de dependencias, mapear tu grafo de dependencias, definir SLOs para tus dependencias e implementar degradación elegante usando feature flags. Todo con ejemplos prácticos en Elixir y Kubernetes.


Vamos al tema.


Dependencias como riesgo

No todas las dependencias son iguales. El primer paso para gestionarlas es entender con qué tipo de dependencia estás tratando y qué pasa cuando falla.


Hay dos tipos fundamentales de dependencias:


  • Dependencias duras: Tu servicio no puede funcionar en absoluto sin ellas. Si tu base de datos se cae, probablemente no podés servir ninguna request. Si el servicio de autenticación no responde, nadie puede iniciar sesión.
  • Dependencias blandas: Tu servicio puede seguir funcionando sin ellas, posiblemente en un estado degradado. Si el motor de recomendaciones se cae, igual podés mostrar la página del producto sin recomendaciones. Si el servicio de analytics anda lento, podés hacer fire and forget.

El peligro viene de las fallas en cascada. Considerá este escenario: el Servicio A llama al Servicio B, que llama al Servicio C. El Servicio C empieza a responder lento por un problema en la base de datos. Los threads del Servicio B quedan bloqueados esperando al Servicio C. Los tiempos de respuesta del Servicio B aumentan. Los threads del Servicio A quedan bloqueados esperando al Servicio B. En poco tiempo, los tres servicios están efectivamente caídos por una sola consulta lenta en la base de datos del Servicio C.


Por eso la gestión de dependencias importa tanto. Una sola dependencia que se porta mal puede tirar abajo todo tu sistema si no tenés las protecciones adecuadas. Veamos los patrones que previenen esto.


Circuit breakers

El patrón circuit breaker viene de la ingeniería eléctrica. Cuando fluye demasiada corriente por un circuito, el interruptor se dispara y corta el flujo para prevenir daños. En software, cuando una dependencia empieza a fallar, el circuit breaker se dispara y deja de enviar requests, dándole tiempo para recuperarse.


Un circuit breaker tiene tres estados:


  • Cerrado: Todo normal. Las requests fluyen hacia la dependencia. El breaker monitorea tasas de error.
  • Abierto: La dependencia está fallando. Las requests se rechazan inmediatamente sin llamar a la dependencia. Se inicia un timer.
  • Semi-abierto: El timer expiró. Se envía un número limitado de requests de prueba. Si tienen éxito, el breaker se cierra. Si fallan, el breaker se abre de nuevo.

Acá hay una implementación práctica en Elixir usando un GenServer:

defmodule MyApp.CircuitBreaker do
  use GenServer

  @failure_threshold 5
  @reset_timeout_ms 30_000
  @half_open_max_calls 3

  defstruct [
    :name,
    state: :closed,
    failure_count: 0,
    success_count: 0,
    last_failure_time: nil,
    half_open_calls: 0
  ]

  # API del cliente

  def start_link(opts) do
    name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, %__MODULE__{name: name}, name: name)
  end

  def call(name, func) when is_function(func, 0) do
    case GenServer.call(name, :check_state) do
      :ok ->
        try do
          result = func.()
          GenServer.cast(name, :record_success)
          {:ok, result}
        rescue
          error ->
            GenServer.cast(name, :record_failure)
            {:error, :dependency_error, error}
        end

      :open ->
        {:error, :circuit_open}
    end
  end

  # Callbacks del servidor

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call(:check_state, _from, %{state: :closed} = state) do
    {:reply, :ok, state}
  end

  def handle_call(:check_state, _from, %{state: :open} = state) do
    if time_since_last_failure(state) >= @reset_timeout_ms do
      {:reply, :ok, %{state | state: :half_open, half_open_calls: 0}}
    else
      {:reply, :open, state}
    end
  end

  def handle_call(:check_state, _from, %{state: :half_open} = state) do
    if state.half_open_calls < @half_open_max_calls do
      {:reply, :ok, %{state | half_open_calls: state.half_open_calls + 1}}
    else
      {:reply, :open, state}
    end
  end

  @impl true
  def handle_cast(:record_success, %{state: :half_open} = state) do
    {:noreply, %{state | state: :closed, failure_count: 0, success_count: 0}}
  end

  def handle_cast(:record_success, state) do
    {:noreply, %{state | success_count: state.success_count + 1}}
  end

  def handle_cast(:record_failure, state) do
    new_count = state.failure_count + 1
    now = System.monotonic_time(:millisecond)

    new_state =
      if new_count >= @failure_threshold do
        %{state | state: :open, failure_count: new_count, last_failure_time: now}
      else
        %{state | failure_count: new_count, last_failure_time: now}
      end

    {:noreply, new_state}
  end

  defp time_since_last_failure(%{last_failure_time: nil}), do: :infinity

  defp time_since_last_failure(%{last_failure_time: time}) do
    System.monotonic_time(:millisecond) - time
  end
end

Y acá está cómo lo usarías en tu aplicación:

# En tu árbol de supervisión
children = [
  {MyApp.CircuitBreaker, name: :payment_service},
  {MyApp.CircuitBreaker, name: :auth_service},
  {MyApp.CircuitBreaker, name: :recommendation_engine}
]

# Cuando hacés una llamada a una dependencia
case MyApp.CircuitBreaker.call(:payment_service, fn ->
  HTTPoison.post("https://payments.internal/charge", body, headers)
end) do
  {:ok, %{status_code: 200, body: body}} ->
    {:ok, Jason.decode!(body)}

  {:error, :circuit_open} ->
    Logger.warning("Circuito del servicio de pagos abierto, usando fallback")
    {:error, :service_unavailable}

  {:error, :dependency_error, error} ->
    Logger.error("Error del servicio de pagos: #{inspect(error)}")
    {:error, :payment_failed}
end

La idea clave acá es que cuando el circuito está abierto, fallás rápido. En vez de esperar 30 segundos por un timeout de un servicio muerto, obtenés una respuesta inmediata y podés ejecutar tu lógica de fallback. Esto protege tanto a tu servicio como a la dependencia que está fallando, ya que no le estás apilando más requests mientras intenta recuperarse.


Patrón bulkhead

El patrón bulkhead viene del diseño de barcos. Los barcos tienen compartimentos (mamparos) para que si una sección se inunda, el resto del barco se mantenga a flote. En software, la idea es aislar dominios de falla para que un problema en un área no afecte todo lo demás.


Elixir y la VM BEAM son particularmente buenos para esto por el aislamiento de procesos. Cada proceso es independiente, tiene su propia memoria, y si se cae, otros procesos no se ven afectados. Podés usar esto para crear bulkheads naturales:

defmodule MyApp.DependencyPool do
  @moduledoc """
  Gestiona pools de procesos separados para cada dependencia,
  previniendo que una dependencia lenta consuma todos los recursos.
  """

  def child_spec(_opts) do
    children = [
      # Cada dependencia tiene su propio pool con sus propios límites
      pool_spec(:payment_pool, MyApp.PaymentWorker, size: 10, max_overflow: 5),
      pool_spec(:auth_pool, MyApp.AuthWorker, size: 20, max_overflow: 10),
      pool_spec(:recommendation_pool, MyApp.RecommendationWorker, size: 5, max_overflow: 2),
      pool_spec(:notification_pool, MyApp.NotificationWorker, size: 5, max_overflow: 5)
    ]

    %{
      id: __MODULE__,
      type: :supervisor,
      start: {Supervisor, :start_link, [children, [strategy: :one_for_one]]}
    }
  end

  defp pool_spec(name, worker, opts) do
    pool_opts = [
      name: {:local, name},
      worker_module: worker,
      size: Keyword.fetch!(opts, :size),
      max_overflow: Keyword.fetch!(opts, :max_overflow)
    ]

    :poolboy.child_spec(name, pool_opts)
  end

  def call_dependency(pool_name, request, timeout \\ 5_000) do
    try do
      :poolboy.transaction(
        pool_name,
        fn worker -> GenServer.call(worker, {:request, request}, timeout) end,
        timeout
      )
    catch
      :exit, {:timeout, _} ->
        {:error, :pool_timeout}

      :exit, {:noproc, _} ->
        {:error, :pool_unavailable}
    end
  end
end

En Kubernetes, tenés otra capa de aislamiento a través de límites de recursos. Cada servicio tiene su propio presupuesto de CPU y memoria, así que una dependencia descontrolada no puede dejar sin recursos a otros servicios:

# k8s/deployment-con-bulkheads.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
---
# Cuotas de recursos separadas por namespace actúan como bulkheads
apiVersion: v1
kind: ResourceQuota
metadata:
  name: dependency-quota
  namespace: payment-service
spec:
  hard:
    requests.cpu: "2"
    requests.memory: "4Gi"
    limits.cpu: "4"
    limits.memory: "8Gi"
    pods: "20"
---
# Network policies como otra forma de bulkhead
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payment-service-policy
  namespace: payment-service
spec:
  podSelector:
    matchLabels:
      app: payment-service
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: my-app
      ports:
        - port: 8080
          protocol: TCP

La combinación de aislamiento de procesos de Elixir, pools de conexiones, límites de recursos de Kubernetes y network policies te da múltiples capas de aislamiento. Si el servicio de pagos se descontrola, no puede consumir toda la CPU del nodo, no puede agotar el pool de conexiones de tu aplicación para otros servicios, y no puede afectar procesos que manejan requests que no necesitan pagos.


Timeouts y reintentos

Los timeouts y reintentos parecen simples, pero hacerlos mal es una de las causas más comunes de fallas en cascada. Empecemos con lo que no hay que hacer.


El enfoque ingenuo se ve así:

# NO hagas esto - reintentos infinitos sin backoff
def fetch_user(user_id) do
  case HTTPoison.get("https://auth.internal/users/#{user_id}") do
    {:ok, response} -> {:ok, response}
    {:error, _} -> fetch_user(user_id)  # loop de reintentos infinito!
  end
end

Esto crea una tormenta de reintentos. Si el servicio de autenticación se cae, cada request a tu servicio va a generar reintentos infinitos, empeorando el problema. Acá está la forma correcta de hacerlo con backoff exponencial y jitter:

defmodule MyApp.Retry do
  @moduledoc """
  Reintentos con backoff exponencial y jitter.
  """

  @default_opts [
    max_retries: 3,
    base_delay_ms: 100,
    max_delay_ms: 5_000,
    jitter: true
  ]

  def with_retry(func, opts \\ []) when is_function(func, 0) do
    opts = Keyword.merge(@default_opts, opts)
    do_retry(func, 0, opts)
  end

  defp do_retry(func, attempt, opts) do
    case func.() do
      {:ok, result} ->
        {:ok, result}

      {:error, reason} when attempt < opts[:max_retries] ->
        delay = calculate_delay(attempt, opts)
        Logger.info("Reintento #{attempt + 1} después de #{delay}ms, razón: #{inspect(reason)}")
        Process.sleep(delay)
        do_retry(func, attempt + 1, opts)

      {:error, reason} ->
        Logger.warning("Los #{opts[:max_retries]} reintentos se agotaron, razón: #{inspect(reason)}")
        {:error, :retries_exhausted, reason}
    end
  end

  defp calculate_delay(attempt, opts) do
    # Backoff exponencial: base * 2^intento
    base_delay = opts[:base_delay_ms] * Integer.pow(2, attempt)

    # Tope en el delay máximo
    capped_delay = min(base_delay, opts[:max_delay_ms])

    # Agregar jitter para prevenir thundering herd
    if opts[:jitter] do
      jitter_range = div(capped_delay, 2)
      capped_delay - jitter_range + :rand.uniform(jitter_range * 2)
    else
      capped_delay
    end
  end
end

Y acá está cómo combinás los reintentos con el circuit breaker:

defmodule MyApp.ResilientClient do
  alias MyApp.{CircuitBreaker, Retry}

  def call_service(circuit_name, request_fn, opts \\ []) do
    CircuitBreaker.call(circuit_name, fn ->
      Retry.with_retry(fn ->
        case request_fn.() do
          {:ok, %{status_code: status} = resp} when status in 200..299 ->
            {:ok, resp}

          {:ok, %{status_code: status}} when status in [429, 503] ->
            # Errores de servidor reintentables
            {:error, :retryable}

          {:ok, %{status_code: status} = resp} ->
            # Errores de cliente no reintentables
            {:ok, resp}

          {:error, %HTTPoison.Error{reason: reason}} ->
            {:error, reason}
        end
      end, opts)
    end)
  end
end

# Uso
MyApp.ResilientClient.call_service(:payment_service, fn ->
  HTTPoison.post(url, body, headers, recv_timeout: 5_000)
end, max_retries: 2, base_delay_ms: 200)

Hay algunas cosas importantes para tener en cuenta acá:


  • Siempre poné timeouts: Nunca hagas una llamada de red sin timeout. Un timeout por defecto de 5 segundos es un punto de partida razonable.
  • El jitter es esencial: Sin jitter, todos los reintentos pasan al mismo tiempo, creando un thundering herd. Agregar aleatoriedad los distribuye.
  • No todo es reintentable: Solo reintentá en errores transitorios (timeouts, 503s, resets de conexión). No reintentes en 400s o 404s.
  • Poné un presupuesto de reintentos: Limitá el número total de reintentos a través de todas las requests, no solo por request. Si el 50% de tus requests están reintentando, algo está muy mal.
  • Combiná con circuit breakers: Reintentos sin circuit breaker pueden empeorar una situación mala. El circuit breaker para la hemorragia cuando los reintentos no ayudan.

Estrategias de fallback

Cuando una dependencia falla y el circuit breaker está abierto, necesitás un plan B. Las estrategias de fallback definen qué hace tu servicio cuando no puede alcanzar una dependencia. La estrategia correcta depende de la dependencia y de lo que tus usuarios esperan.


Acá están los patrones de fallback más comunes:


1. Fallback de caché

Servir datos desactualizados desde un caché local cuando la fuente no está disponible:

defmodule MyApp.CacheFallback do
  use GenServer

  @cache_ttl_ms 300_000  # 5 minutos
  @stale_ttl_ms 3_600_000  # 1 hora - datos viejos son mejor que ningún dato

  def get_user_profile(user_id) do
    case MyApp.ResilientClient.call_service(:user_service, fn ->
      HTTPoison.get("https://users.internal/profiles/#{user_id}", [],
        recv_timeout: 3_000
      )
    end) do
      {:ok, %{status_code: 200, body: body}} ->
        profile = Jason.decode!(body)
        cache_put(user_id, profile)
        {:ok, profile}

      {:error, _reason} ->
        case cache_get(user_id) do
          {:ok, profile, :fresh} ->
            {:ok, profile}

          {:ok, profile, :stale} ->
            Logger.info("Sirviendo perfil desactualizado para usuario #{user_id}")
            {:ok, Map.put(profile, :_stale, true)}

          :miss ->
            {:error, :unavailable}
        end
    end
  end

  defp cache_put(key, value) do
    :ets.insert(:profile_cache, {key, value, System.monotonic_time(:millisecond)})
  end

  defp cache_get(key) do
    case :ets.lookup(:profile_cache, key) do
      [{^key, value, cached_at}] ->
        age = System.monotonic_time(:millisecond) - cached_at

        cond do
          age < @cache_ttl_ms -> {:ok, value, :fresh}
          age < @stale_ttl_ms -> {:ok, value, :stale}
          true -> :miss
        end

      [] ->
        :miss
    end
  end
end

2. Fallback de respuesta por defecto

Devolver un valor sensato por defecto cuando la dependencia no está disponible:

defmodule MyApp.RecommendationService do
  @default_recommendations [
    %{id: "popular-1", title: "Artículo Más Popular", reason: "trending"},
    %{id: "popular-2", title: "Selección del Editor", reason: "curated"},
    %{id: "popular-3", title: "Recién Llegado", reason: "new"}
  ]

  def get_recommendations(user_id) do
    case MyApp.ResilientClient.call_service(:recommendation_engine, fn ->
      HTTPoison.get("https://recommendations.internal/for/#{user_id}", [],
        recv_timeout: 2_000
      )
    end) do
      {:ok, %{status_code: 200, body: body}} ->
        {:ok, Jason.decode!(body)}

      {:error, _reason} ->
        Logger.info("Motor de recomendaciones no disponible, usando defaults")
        {:ok, @default_recommendations}
    end
  end
end

3. Fallback de modo degradado

Deshabilitar funcionalidades no esenciales y comunicar el estado degradado a los usuarios:

defmodule MyApp.DegradedMode do
  @moduledoc """
  Rastrea qué funcionalidades están operando en modo degradado
  y provee respuestas apropiadas.
  """

  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def mark_degraded(feature, reason) do
    GenServer.cast(__MODULE__, {:mark_degraded, feature, reason})
  end

  def mark_healthy(feature) do
    GenServer.cast(__MODULE__, {:mark_healthy, feature})
  end

  def degraded?(feature) do
    GenServer.call(__MODULE__, {:degraded?, feature})
  end

  def status do
    GenServer.call(__MODULE__, :status)
  end

  @impl true
  def init(_) do
    {:ok, %{}}
  end

  @impl true
  def handle_cast({:mark_degraded, feature, reason}, state) do
    Logger.warning("Funcionalidad #{feature} entrando en modo degradado: #{reason}")
    {:noreply, Map.put(state, feature, %{reason: reason, since: DateTime.utc_now()})}
  end

  def handle_cast({:mark_healthy, feature}, state) do
    if Map.has_key?(state, feature) do
      Logger.info("Funcionalidad #{feature} recuperada del modo degradado")
    end

    {:noreply, Map.delete(state, feature)}
  end

  @impl true
  def handle_call({:degraded?, feature}, _from, state) do
    {:reply, Map.has_key?(state, feature), state}
  end

  def handle_call(:status, _from, state) do
    {:reply, state, state}
  end
end

4. Fallback estático

Para servicios con mucha lectura, pre-computar respuestas estáticas que se puedan servir cuando todo lo demás falla:

defmodule MyApp.StaticFallback do
  @moduledoc """
  Sirve contenido estático pre-computado cuando los servicios dinámicos fallan.
  Actualizado periódicamente por un job en segundo plano.
  """

  @static_dir "priv/static/fallbacks"

  def get_homepage_data do
    case fetch_dynamic_homepage() do
      {:ok, data} -> {:ok, data}
      {:error, _} -> load_static_fallback("homepage.json")
    end
  end

  defp load_static_fallback(filename) do
    path = Path.join(@static_dir, filename)

    case File.read(path) do
      {:ok, content} ->
        Logger.info("Sirviendo fallback estático: #{filename}")
        {:ok, Jason.decode!(content)}

      {:error, _} ->
        {:error, :no_fallback_available}
    end
  end
end

Lo importante es planificar tus fallbacks antes de necesitarlos. Durante un incidente no es el momento de ponerte a pensar qué debería hacer tu servicio cuando el motor de recomendaciones se cae. Documentá tu estrategia de fallback para cada dependencia y probala regularmente.


Health checks para dependencias

Kubernetes te da tres tipos de probes, y entender cuándo usar cada uno es crítico para la gestión de dependencias:


  • Liveness probes: “¿Está vivo este proceso?” Si falla, Kubernetes reinicia el container. Esto debería verificar tu proceso, no tus dependencias. Si tu base de datos se cae, reiniciar tu app no lo va a arreglar.
  • Readiness probes: “¿Puede este pod servir tráfico?” Si falla, Kubernetes remueve el pod de los endpoints del servicio. Acá es donde verificás dependencias. Si no podés alcanzar la base de datos, no deberías recibir tráfico.
  • Startup probes: “¿Este pod terminó de arrancar?” Le da tiempo a containers que arrancan lento para inicializarse antes de que empiecen los checks de liveness y readiness.

Acá hay una implementación de health check con consciencia de dependencias:

defmodule MyAppWeb.HealthController do
  use MyAppWeb, :controller

  @hard_dependencies [:database, :cache]
  @soft_dependencies [:recommendation_engine, :notification_service]

  # Liveness: solo verifica si el proceso está vivo
  def liveness(conn, _params) do
    json(conn, %{status: "alive", timestamp: DateTime.utc_now()})
  end

  # Readiness: verifica dependencias duras
  def readiness(conn, _params) do
    checks =
      @hard_dependencies
      |> Enum.map(fn dep -> {dep, check_dependency(dep)} end)
      |> Map.new()

    all_healthy = Enum.all?(checks, fn {_dep, status} -> status == :ok end)

    if all_healthy do
      conn
      |> put_status(200)
      |> json(%{status: "ready", checks: format_checks(checks)})
    else
      conn
      |> put_status(503)
      |> json(%{status: "not_ready", checks: format_checks(checks)})
    end
  end

  # Estado completo: verifica todo incluyendo dependencias blandas
  def status(conn, _params) do
    hard_checks =
      @hard_dependencies
      |> Enum.map(fn dep -> {dep, check_dependency(dep)} end)
      |> Map.new()

    soft_checks =
      @soft_dependencies
      |> Enum.map(fn dep -> {dep, check_dependency(dep)} end)
      |> Map.new()

    degraded_features = MyApp.DegradedMode.status()

    all_hard_healthy = Enum.all?(hard_checks, fn {_dep, s} -> s == :ok end)
    all_soft_healthy = Enum.all?(soft_checks, fn {_dep, s} -> s == :ok end)

    overall =
      cond do
        not all_hard_healthy -> "unhealthy"
        not all_soft_healthy -> "degraded"
        true -> "healthy"
      end

    conn
    |> put_status(if(all_hard_healthy, do: 200, else: 503))
    |> json(%{
      status: overall,
      hard_dependencies: format_checks(hard_checks),
      soft_dependencies: format_checks(soft_checks),
      degraded_features: degraded_features
    })
  end

  defp check_dependency(:database) do
    case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  defp check_dependency(:cache) do
    case Redix.command(:redix, ["PING"]) do
      {:ok, "PONG"} -> :ok
      _ -> :error
    end
  end

  defp check_dependency(name) do
    case MyApp.CircuitBreaker.call(name, fn ->
      HTTPoison.get("https://#{name}.internal/health", [], recv_timeout: 2_000)
    end) do
      {:ok, %{status_code: 200}} -> :ok
      _ -> :error
    end
  end

  defp format_checks(checks) do
    Map.new(checks, fn {dep, status} ->
      {dep, %{status: status, checked_at: DateTime.utc_now()}}
    end)
  end
end

Y la configuración correspondiente de probes en Kubernetes:

# k8s/deployment-con-probes.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          ports:
            - containerPort: 4000
          livenessProbe:
            httpGet:
              path: /health/live
              port: 4000
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 4000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 2
          startupProbe:
            httpGet:
              path: /health/live
              port: 4000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30

El error crítico que la gente comete es poner verificaciones de dependencias en los liveness probes. Si tu base de datos se cae y tu liveness probe verifica la base de datos, Kubernetes va a reiniciar todos tus pods. Ahora tenés una caída de base de datos y una tormenta de reinicios de aplicación pasando al mismo tiempo. Mantené los liveness probes simples y usá readiness probes para verificar dependencias.


Mapeo de dependencias

Antes de poder gestionar tus dependencias, necesitás verlas. Un mapa de dependencias es una representación visual de todos los servicios en tu sistema y cómo se conectan. Esto suena obvio, pero te sorprendería cuántos equipos no tienen una imagen clara de su grafo de dependencias.


Acá hay una forma simple de documentar tus dependencias:

defmodule MyApp.DependencyMap do
  @moduledoc """
  Declara todas las dependencias de servicio con sus propiedades.
  Esto sirve como documentación viva y alimenta decisiones en runtime.
  """

  @dependencies %{
    database: %{
      type: :hard,
      url: "postgresql://db.internal:5432/myapp",
      timeout_ms: 5_000,
      circuit_breaker: false,  # gestionado por el pool de Ecto
      fallback: :none,
      slo_target: 0.999,
      owner_team: "platform",
      criticality: :critical
    },
    cache: %{
      type: :hard,
      url: "redis://cache.internal:6379",
      timeout_ms: 1_000,
      circuit_breaker: true,
      fallback: :bypass,  # saltear caché, ir directo a la base de datos
      slo_target: 0.999,
      owner_team: "platform",
      criticality: :critical
    },
    auth_service: %{
      type: :hard,
      url: "https://auth.internal:8443",
      timeout_ms: 3_000,
      circuit_breaker: true,
      fallback: :cached_tokens,
      slo_target: 0.999,
      owner_team: "identity",
      criticality: :critical
    },
    payment_service: %{
      type: :hard,
      url: "https://payments.internal:8080",
      timeout_ms: 10_000,
      circuit_breaker: true,
      fallback: :queue_for_retry,
      slo_target: 0.999,
      owner_team: "payments",
      criticality: :high
    },
    recommendation_engine: %{
      type: :soft,
      url: "https://recommendations.internal:8080",
      timeout_ms: 2_000,
      circuit_breaker: true,
      fallback: :static_defaults,
      slo_target: 0.99,
      owner_team: "ml",
      criticality: :low
    },
    notification_service: %{
      type: :soft,
      url: "https://notifications.internal:8080",
      timeout_ms: 5_000,
      circuit_breaker: true,
      fallback: :queue_for_retry,
      slo_target: 0.99,
      owner_team: "comms",
      criticality: :medium
    },
    analytics_service: %{
      type: :soft,
      url: "https://analytics.internal:8080",
      timeout_ms: 1_000,
      circuit_breaker: true,
      fallback: :fire_and_forget,
      slo_target: 0.95,
      owner_team: "data",
      criticality: :low
    }
  }

  def all, do: @dependencies

  def hard_dependencies do
    @dependencies
    |> Enum.filter(fn {_name, config} -> config.type == :hard end)
    |> Map.new()
  end

  def soft_dependencies do
    @dependencies
    |> Enum.filter(fn {_name, config} -> config.type == :soft end)
    |> Map.new()
  end

  def get(name), do: Map.get(@dependencies, name)

  def critical_path do
    @dependencies
    |> Enum.filter(fn {_name, config} -> config.criticality in [:critical, :high] end)
    |> Enum.sort_by(fn {_name, config} -> config.criticality end)
    |> Map.new()
  end
end

Este tipo de mapa de dependencias declarativo sirve para múltiples propósitos: documenta de qué dependés, alimenta la configuración de tus circuit breakers, informa a tus health checks, y le dice a los ingenieros de guardia a qué equipo contactar cuando una dependencia falla.


También podés generar un grafo visual a partir de estos datos:

defmodule MyApp.DependencyGraph do
  @moduledoc """
  Genera un diagrama Mermaid a partir del mapa de dependencias.
  """

  def to_mermaid do
    deps = MyApp.DependencyMap.all()

    nodes =
      deps
      |> Enum.map(fn {name, config} ->
        style = if config.type == :hard, do: ":::critical", else: ":::optional"
        "  #{name}[#{name}]#{style}"
      end)
      |> Enum.join("\n")

    edges =
      deps
      |> Enum.map(fn {name, config} ->
        arrow = if config.type == :hard, do: "==>", else: "-->"
        "  my_app #{arrow} #{name}"
      end)
      |> Enum.join("\n")

    """
    graph LR
      my_app[My App]
    #{nodes}
    #{edges}
      classDef critical fill:#ff6b6b,stroke:#333
      classDef optional fill:#4ecdc4,stroke:#333
    """
  end
end

SLOs para dependencias

Así como definís SLOs para tus propios servicios, deberías rastrear la confiabilidad de tus dependencias. Esto te da datos para tomar decisiones sobre arquitectura, estrategias de fallback, e incluso selección de proveedores.


Acá está cómo pensar sobre SLOs de dependencias:


  • Dependencias internas: Generalmente podés negociar SLOs con el equipo que es dueño del servicio. “Necesitamos que tu servicio de autenticación tenga 99.9% de disponibilidad y latencia p99 menor a 200ms.”
  • Dependencias externas: Estás a merced del SLA del proveedor. Rastreá el rendimiento real contra su SLA declarado, porque la realidad suele diferir.
  • Tu SLO efectivo: El SLO de tu servicio no puede ser más alto que el SLO de tu dependencia dura más débil. Si el SLO de tu base de datos es 99.9%, el SLO de tu servicio no puede ser de forma realista 99.95%.

Acá hay un enfoque basado en Prometheus para rastrear SLOs de dependencias:

# prometheus-rules-dependency-slos.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: dependency-slos
  namespace: monitoring
spec:
  groups:
    - name: dependency.slos
      interval: 30s
      rules:
        # Rastrear tasa de éxito por dependencia
        - record: dependency:requests:success_rate5m
          expr: |
            sum by (dependency) (
              rate(dependency_requests_total{status="success"}[5m])
            ) /
            sum by (dependency) (
              rate(dependency_requests_total[5m])
            )

        # Rastrear latencia por dependencia
        - record: dependency:latency:p99_5m
          expr: |
            histogram_quantile(0.99,
              sum by (dependency, le) (
                rate(dependency_request_duration_seconds_bucket[5m])
              )
            )

        # Presupuesto de error restante de dependencia (ventana de 30 días)
        - record: dependency:error_budget:remaining
          expr: |
            1 - (
              (1 - avg_over_time(dependency:requests:success_rate5m[30d]))
              /
              (1 - 0.999)
            )

    - name: dependency.alerts
      rules:
        - alert: DependencyErrorBudgetBurning
          expr: dependency:error_budget:remaining < 0.5
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "La dependencia {{ $labels.dependency }} consumió 50% del presupuesto de error"
            description: "Presupuesto de error restante: {{ $value | humanizePercentage }}"

        - alert: DependencyErrorBudgetExhausted
          expr: dependency:error_budget:remaining < 0.1
          for: 5m
          labels:
            severity: critical
          annotations:
            summary: "Presupuesto de error de dependencia {{ $labels.dependency }} casi agotado"
            description: "Presupuesto de error restante: {{ $value | humanizePercentage }}"

Para emitir estas métricas desde tu aplicación Elixir, instrumentá tus llamadas a dependencias:

defmodule MyApp.DependencyTelemetry do
  @moduledoc """
  Emite eventos de telemetría para todas las llamadas a dependencias,
  que luego se exponen como métricas de Prometheus.
  """

  def track_call(dependency, func) when is_function(func, 0) do
    start_time = System.monotonic_time()

    result =
      try do
        func.()
      rescue
        error ->
          duration = System.monotonic_time() - start_time

          :telemetry.execute(
            [:dependency, :call, :exception],
            %{duration: duration},
            %{dependency: dependency, error: inspect(error)}
          )

          reraise error, __STACKTRACE__
      end

    duration = System.monotonic_time() - start_time
    status = if match?({:ok, _}, result), do: "success", else: "failure"

    :telemetry.execute(
      [:dependency, :call, :stop],
      %{duration: duration},
      %{dependency: dependency, status: status}
    )

    result
  end
end

Cuando rastreás SLOs de dependencias a lo largo del tiempo, empezás a ver patrones. Quizás tu motor de recomendaciones cae por debajo de su SLO todos los lunes a la mañana cuando el equipo de ML corre jobs batch. Quizás el gateway de pagos tiene caídas de confiabilidad el último día del mes. Estos patrones te ayudan a planificar mejores estrategias de fallback y a tener conversaciones informadas con los dueños de las dependencias.


Patrones de degradación elegante

La degradación elegante es el arte de hacer menos, bien, en vez de hacer todo, mal. Cuando tu sistema está bajo estrés o una dependencia está fallando, reducís intencionalmente la funcionalidad para proteger la experiencia central del usuario.


Pensalo como niveles progresivos de degradación:


  1. Nivel 0 - Normal: Todas las funcionalidades andando, todas las dependencias sanas
  2. Nivel 1 - Reducido: Funcionalidades no esenciales deshabilitadas (recomendaciones, analytics, personalización)
  3. Nivel 2 - Solo lo central: Solo quedan las funcionalidades del camino crítico (navegar, buscar, comprar)
  4. Nivel 3 - Mínimo: Modo solo lectura o solo contenido estático
  5. Nivel 4 - Mantenimiento: Servicio caído, mostrar página de mantenimiento

Acá está cómo implementar degradación progresiva:

defmodule MyApp.DegradationLevel do
  @moduledoc """
  Gestiona el nivel de degradación actual basado en
  la salud de dependencias y la carga del sistema.
  """

  use GenServer

  @levels [:normal, :reduced, :core_only, :minimal, :maintenance]

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, :normal, name: __MODULE__)
  end

  def current_level do
    GenServer.call(__MODULE__, :current_level)
  end

  def set_level(level) when level in @levels do
    GenServer.call(__MODULE__, {:set_level, level})
  end

  def feature_available?(feature) do
    level = current_level()
    feature_level = feature_minimum_level(feature)
    level_index(level) <= level_index(feature_level)
  end

  @impl true
  def init(level), do: {:ok, level}

  @impl true
  def handle_call(:current_level, _from, level), do: {:reply, level, level}

  def handle_call({:set_level, new_level}, _from, old_level) do
    if new_level != old_level do
      Logger.warning(
        "Nivel de degradación cambió: #{old_level} -> #{new_level}"
      )

      :telemetry.execute(
        [:app, :degradation, :level_change],
        %{},
        %{old_level: old_level, new_level: new_level}
      )
    end

    {:reply, :ok, new_level}
  end

  # Definir qué funcionalidades están disponibles en cada nivel
  defp feature_minimum_level(:recommendations), do: :normal
  defp feature_minimum_level(:analytics_tracking), do: :normal
  defp feature_minimum_level(:personalization), do: :normal
  defp feature_minimum_level(:search_suggestions), do: :reduced
  defp feature_minimum_level(:user_reviews), do: :reduced
  defp feature_minimum_level(:search), do: :core_only
  defp feature_minimum_level(:browse_catalog), do: :core_only
  defp feature_minimum_level(:checkout), do: :core_only
  defp feature_minimum_level(:static_content), do: :minimal
  defp feature_minimum_level(_), do: :normal

  defp level_index(:normal), do: 0
  defp level_index(:reduced), do: 1
  defp level_index(:core_only), do: 2
  defp level_index(:minimal), do: 3
  defp level_index(:maintenance), do: 4
end

Después podés usar esto en tus controllers y LiveViews:

defmodule MyAppWeb.ProductLive do
  use MyAppWeb, :live_view

  alias MyApp.DegradationLevel

  def mount(%{"id" => id}, _session, socket) do
    product = MyApp.Catalog.get_product!(id)

    socket =
      socket
      |> assign(:product, product)
      |> assign(:degradation_level, DegradationLevel.current_level())
      |> maybe_load_recommendations(id)
      |> maybe_load_reviews(id)

    {:ok, socket}
  end

  defp maybe_load_recommendations(socket, product_id) do
    if DegradationLevel.feature_available?(:recommendations) do
      case MyApp.RecommendationService.get_recommendations(product_id) do
        {:ok, recs} -> assign(socket, :recommendations, recs)
        {:error, _} -> assign(socket, :recommendations, [])
      end
    else
      assign(socket, :recommendations, [])
    end
  end

  defp maybe_load_reviews(socket, product_id) do
    if DegradationLevel.feature_available?(:user_reviews) do
      case MyApp.Reviews.list_for_product(product_id) do
        {:ok, reviews} -> assign(socket, :reviews, reviews)
        {:error, _} -> assign(socket, :reviews, [])
      end
    else
      assign(socket, :reviews, [])
    end
  end
end

Feature flags para degradación

Los feature flags son el mecanismo que hace que la degradación elegante sea práctica en runtime. En vez de deployar código nuevo para deshabilitar una funcionalidad, girás un flag y el cambio toma efecto de inmediato.


Acá hay una implementación de feature flags simple pero efectiva en Elixir:

defmodule MyApp.FeatureFlags do
  @moduledoc """
  Feature flags basados en ETS para toggling en runtime.
  Soporta flags booleanos y rollouts por porcentaje.
  """

  use GenServer

  @table :feature_flags

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl true
  def init(_) do
    :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])

    # Cargar flags por defecto
    load_defaults()

    {:ok, %{}}
  end

  # Verificar si una funcionalidad está habilitada
  def enabled?(flag) do
    case :ets.lookup(@table, flag) do
      [{^flag, true}] -> true
      [{^flag, false}] -> false
      [{^flag, percentage}] when is_integer(percentage) ->
        :rand.uniform(100) <= percentage
      [] -> true  # habilitado por defecto si el flag no existe
    end
  end

  # Habilitar una funcionalidad
  def enable(flag) do
    :ets.insert(@table, {flag, true})
    Logger.info("Feature flag habilitado: #{flag}")
    :ok
  end

  # Deshabilitar una funcionalidad
  def disable(flag) do
    :ets.insert(@table, {flag, false})
    Logger.warning("Feature flag deshabilitado: #{flag}")
    :ok
  end

  # Configurar rollout por porcentaje
  def set_percentage(flag, percentage) when percentage in 0..100 do
    :ets.insert(@table, {flag, percentage})
    Logger.info("Feature flag #{flag} configurado al #{percentage}%")
    :ok
  end

  # Listar todos los flags y sus estados
  def list_all do
    :ets.tab2list(@table)
    |> Map.new()
  end

  defp load_defaults do
    defaults = [
      {:recommendations, true},
      {:analytics_tracking, true},
      {:personalization, true},
      {:search_suggestions, true},
      {:user_reviews, true},
      {:new_checkout_flow, false},
      {:experimental_search, 10}  # rollout al 10%
    ]

    Enum.each(defaults, fn {flag, value} ->
      :ets.insert(@table, {flag, value})
    end)
  end
end

Y una página de Phoenix LiveDashboard para gestionar flags en runtime:

defmodule MyAppWeb.FeatureFlagController do
  use MyAppWeb, :controller

  plug :require_admin

  def index(conn, _params) do
    flags = MyApp.FeatureFlags.list_all()
    json(conn, %{flags: flags})
  end

  def update(conn, %{"flag" => flag, "value" => "true"}) do
    MyApp.FeatureFlags.enable(String.to_existing_atom(flag))
    json(conn, %{status: "ok", flag: flag, value: true})
  end

  def update(conn, %{"flag" => flag, "value" => "false"}) do
    MyApp.FeatureFlags.disable(String.to_existing_atom(flag))
    json(conn, %{status: "ok", flag: flag, value: false})
  end

  def update(conn, %{"flag" => flag, "value" => value}) do
    case Integer.parse(value) do
      {percentage, ""} when percentage in 0..100 ->
        MyApp.FeatureFlags.set_percentage(
          String.to_existing_atom(flag),
          percentage
        )
        json(conn, %{status: "ok", flag: flag, value: percentage})

      _ ->
        conn
        |> put_status(400)
        |> json(%{error: "Valor inválido"})
    end
  end

  defp require_admin(conn, _opts) do
    # Tu lógica de autenticación de admin acá
    conn
  end
end

Lo lindo de combinar feature flags con el sistema de niveles de degradación es que podés automatizar la respuesta a fallas de dependencias. Cuando el circuit breaker del motor de recomendaciones se abre, automáticamente deshabilitás el feature flag de recomendaciones. Cuando se recupera, lo volvés a habilitar:

defmodule MyApp.DegradationAutomation do
  @moduledoc """
  Ajusta automáticamente feature flags y nivel de degradación
  basado en señales de salud de dependencias.
  """

  use GenServer

  @check_interval_ms 10_000

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl true
  def init(_) do
    schedule_check()
    {:ok, %{}}
  end

  @impl true
  def handle_info(:check_dependencies, state) do
    deps = MyApp.DependencyMap.all()

    Enum.each(deps, fn {name, config} ->
      case check_health(name) do
        :healthy ->
          maybe_restore_features(name, config)

        :unhealthy ->
          maybe_degrade_features(name, config)
      end
    end)

    update_overall_degradation_level()
    schedule_check()
    {:noreply, state}
  end

  defp check_health(dep_name) do
    case MyApp.CircuitBreaker.call(dep_name, fn ->
      # health check liviano
      :ok
    end) do
      {:ok, _} -> :healthy
      {:error, :circuit_open} -> :unhealthy
      {:error, _, _} -> :unhealthy
    end
  end

  defp maybe_degrade_features(dep_name, _config) do
    features_for_dependency(dep_name)
    |> Enum.each(fn feature ->
      MyApp.FeatureFlags.disable(feature)
      MyApp.DegradedMode.mark_degraded(feature, "dependencia #{dep_name} no saludable")
    end)
  end

  defp maybe_restore_features(dep_name, _config) do
    features_for_dependency(dep_name)
    |> Enum.each(fn feature ->
      MyApp.FeatureFlags.enable(feature)
      MyApp.DegradedMode.mark_healthy(feature)
    end)
  end

  defp features_for_dependency(:recommendation_engine), do: [:recommendations]
  defp features_for_dependency(:notification_service), do: [:email_notifications]
  defp features_for_dependency(:analytics_service), do: [:analytics_tracking]
  defp features_for_dependency(_), do: []

  defp update_overall_degradation_level do
    hard_deps = MyApp.DependencyMap.hard_dependencies()
    soft_deps = MyApp.DependencyMap.soft_dependencies()

    hard_healthy = Enum.all?(hard_deps, fn {name, _} -> check_health(name) == :healthy end)
    soft_healthy = Enum.all?(soft_deps, fn {name, _} -> check_health(name) == :healthy end)

    level =
      cond do
        not hard_healthy -> :core_only
        not soft_healthy -> :reduced
        true -> :normal
      end

    MyApp.DegradationLevel.set_level(level)
  end

  defp schedule_check do
    Process.send_after(self(), :check_dependencies, @check_interval_ms)
  end
end

Notas finales

La gestión de dependencias y la degradación elegante no son opcionales para cualquier servicio que apunte a ser confiable. Cada llamada externa es un riesgo, y los patrones que cubrimos (circuit breakers, bulkheads, timeouts con backoff, estrategias de fallback, health checks de dependencias, mapeo de dependencias, SLOs de dependencias, niveles de degradación progresiva y feature flags) te dan un toolkit completo para gestionar ese riesgo.


Las conclusiones clave son:


  1. Conocé tus dependencias: Mapealas, clasificalas como duras o blandas, y documentá tu estrategia de fallback para cada una
  2. Fallá rápido: Usá circuit breakers y timeouts para que una dependencia lenta no se convierta en tu problema
  3. Aislá las fallas: Usá bulkheads (pools de procesos, límites de recursos, network policies) para contener el radio de explosión
  4. Tené un plan B: Implementá estrategias de fallback antes de necesitarlas, no durante un incidente
  5. Degradá con elegancia: Es mejor servir una página de producto sin recomendaciones que devolver un error 500
  6. Automatizá la respuesta: Usá feature flags y automatización para responder a fallas de dependencias en segundos, no minutos

Empezá por el camino más crítico de tu sistema. Identificá las dependencias duras, agregá circuit breakers y timeouts, implementá una estrategia de fallback, y probala. No necesitás implementar todo de una. Las mejoras incrementales se acumulan con el tiempo.


¡Espero que te haya resultado útil y lo hayas disfrutado! ¡Hasta la próxima!


Errata

Si encontrás algún error o tenés alguna sugerencia, por favor mandame un mensaje para que se corrija.

También podés revisar el código fuente y los cambios en las fuentes acá



$ Comentarios

Online: 0

Por favor inicie sesión para poder escribir comentarios.

2026-03-17 | Gabriel Garrido