StackPractices
intermediate Por Mathias Paulenko

Patrón Ambassador: Delega Aspectos Transversales a un Proxy

Cómo delegar aspectos transversales a un proxy ambassador entre tus clientes y los servicios externos: pooling de conexiones, reintentos, circuit breaking y TLS.

Visión General

Cada llamada saliente arrastra el mismo equipaje: bucles de reintento, timeouts, configuración TLS, métricas. En un despliegue de microservicios esa lógica termina copiada en cada cliente — y desincronizada a las pocas semanas.

El patrón ambassador mete ese equipaje en un proxy entre tu cliente y el mundo exterior. El cliente llama al ambassador como si fuera el servicio real; el ambassador añade pooling de conexiones, reintentos, circuit breaking y observabilidad en el camino.

Esta página cubre la variante de infraestructura: el ambassador como proceso o contenedor separado — Envoy junto a tu app, Nginx delante de una base de datos, o un proxy compartido para todo un namespace. Para la variante in-process (una clase wrapper dentro del cliente), mirá Patrón Ambassador: Services.

Cuándo Usarlo

  • Varios servicios de un namespace llaman a la misma API externa y deberían compartir un pool de conexiones en vez de mantener cinco.
  • Un cliente legacy o de terceros que no podés recompilar necesita reintentos, TLS o métricas en sus llamadas salientes.
  • El tráfico saliente necesita logging central o rate limiting por auditoría, control de costos o un proveedor inestable.
  • Las conexiones a la base de datos necesitan pooling y el driver no lo trae — el escenario de PgBouncer.

Cuándo No Usarlo

  • La librería del cliente ya resuelve la resiliencia (resilience4j, Polly, reintentos nativos de gRPC). Un proxy encima agrega un salto gratis.
  • La latencia se mide en microsegundos. Un salto de red extra — aunque sea localhost — se nota en la cola de la distribución.
  • Un cliente, un servicio, llamadas simples: un cliente HTTP con timeout alcanza.
  • El “ambassador” es en realidad lógica de negocio. Reglas de precios, validación y orquestación van en el cliente o en el servicio, no en el proxy.

Solución

flowchart diagram: C1[Cliente A

Todos los clientes del diagrama apuntan a la dirección del ambassador, no a la del servicio real. El ambassador es dueño del pool de conexiones upstream, aplica la cadena de políticas de resiliencia y reporta métricas — los clientes quedan tontos.

Ambassador como reverse proxy (Envoy)

# envoy-ambassador.yaml — Ambassador Envoy para tráfico saliente
static_resources:
  listeners:
    - address:
        socket_address:
          address: 0.0.0.0
          port_value: 18080
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: outbound
                route_config:
                  virtual_hosts:
                    - name: external-api
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: external-api
                            retry_policy:
                              retry_on: "5xx,connect-failure,refused-stream"
                              num_retries: 3
                              per_try_timeout: 5s
                            timeout: 30s
                http_filters:
                  - name: envoy.filters.http.router
  clusters:
    - name: external-api
      connect_timeout: 5s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      circuit_breakers:
        thresholds:
          - max_connections: 100
            max_pending_requests: 50
            max_requests: 100
            max_retries: 3
      load_assignment:
        cluster_name: external-api
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: api.external.com
                      port_value: 443
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
          sni: api.external.com

Los clientes pegan a localhost:18080. Envoy hace TLS hacia arriba, reintenta ante 5xx y fallos de conexión, y abre su circuit breaker en los umbrales configurados — sin tocar el código del cliente.

Proxy ambassador en Python

# ambassador_proxy.py — Ambassador Python para llamadas a APIs externas
import requests
import time
import logging
from collections import defaultdict

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class AmbassadorProxy:
    """Proxy que añade reintentos, circuit breaking y monitorización
    a las llamadas salientes sin modificar el cliente."""

    def __init__(self, target_url):
        self.target_url = target_url
        self.failure_count = 0
        self.failure_threshold = 5
        self.recovery_timeout = 30
        self.last_failure_time = None
        self.circuit_open = False
        self.request_stats = defaultdict(lambda: {"count": 0, "errors": 0, "latency_ms": []})

    def _check_circuit(self):
        if self.circuit_open:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                logger.info("Intento de recuperación del circuit breaker")
                self.circuit_open = False
                self.failure_count = 0
            else:
                raise CircuitBreakerOpenError("Circuit breaker abierto")

    def _record_success(self, endpoint, latency_ms):
        self.failure_count = 0
        self.circuit_open = False
        stats = self.request_stats[endpoint]
        stats["count"] += 1
        stats["latency_ms"].append(latency_ms)

    def _record_failure(self, endpoint, latency_ms):
        self.failure_count += 1
        self.last_failure_time = time.time()
        stats = self.request_stats[endpoint]
        stats["count"] += 1
        stats["errors"] += 1
        stats["latency_ms"].append(latency_ms)

        if self.failure_count >= self.failure_threshold:
            self.circuit_open = True
            logger.warning(f"Circuit breaker abierto tras {self.failure_count} fallos")

    def request(self, method, path, max_retries=3, **kwargs):
        endpoint = f"{method} {path}"
        self._check_circuit()

        last_error = None
        for attempt in range(max_retries):
            start = time.time()
            try:
                url = f"{self.target_url}{path}"
                resp = requests.request(method, url, timeout=10, **kwargs)
                latency_ms = (time.time() - start) * 1000

                if resp.status_code < 500:
                    self._record_success(endpoint, latency_ms)
                    return resp
                else:
                    last_error = f"HTTP {resp.status_code}"
                    logger.warning(f"Intento {attempt+1} falló: {last_error}")

            except requests.RequestException as e:
                latency_ms = (time.time() - start) * 1000
                last_error = str(e)
                logger.warning(f"Intento {attempt+1} falló: {e}")

            if attempt < max_retries - 1:
                backoff = 2 ** attempt
                time.sleep(backoff)

        self._record_failure(endpoint, 0)
        raise AmbassadorError(f"Reintentos agotados: {last_error}")

    def get_stats(self):
        return {
            endpoint: {
                "count": s["count"],
                "errors": s["errors"],
                "avg_latency_ms": sum(s["latency_ms"]) / len(s["latency_ms"]) if s["latency_ms"] else 0,
                "circuit_open": self.circuit_open
            }
            for endpoint, s in self.request_stats.items()
        }


class CircuitBreakerOpenError(Exception):
    pass

class AmbassadorError(Exception):
    pass

Cliente usando el ambassador

# client_with_ambassador.py — el cliente habla con el ambassador, no con el servicio externo
from ambassador_proxy import AmbassadorProxy

# El cliente apunta al ambassador, no a la API real
ambassador = AmbassadorProxy("https://api.external.com")

# El código del cliente no sabe nada de reintentos, circuit breaker ni métricas
def get_user_profile(user_id):
    resp = ambassador.request("GET", f"/users/{user_id}")
    return resp.json()

def create_order(order_data):
    resp = ambassador.request(
        "POST", "/orders",
        json=order_data,
        headers={"Content-Type": "application/json"}
    )
    return resp.json()

# Revisar las estadísticas del ambassador
stats = ambassador.get_stats()
for endpoint, s in stats.items():
    print(f"{endpoint}: {s['count']} requests, {s['errors']} errores, media {s['avg_latency_ms']:.0f}ms")

Ambassador Nginx para pooling de conexiones a base de datos

El tráfico TCP en crudo — como PostgreSQL en el puerto 5432 — no pasa por el bloque server HTTP de Nginx. Hace falta el módulo stream, que proxifica conexiones de capa 4:

# nginx-db-ambassador.conf — Ambassador Nginx stream para Postgres
stream {
    upstream database_pool {
        server db-primary:5432 max_conns=100;
        server db-replica:5432 max_conns=50 backup;
    }

    server {
        listen 5432;
        proxy_pass database_pool;
        proxy_connect_timeout 5s;
        proxy_timeout 30s;
    }
}

Los clientes conectan al ambassador en el 5432; Nginx los reparte por el pool y hace failover a la réplica cuando el primario deja de responder.

Ambassador Java con reintentos y métricas

// AmbassadorClient.java — Ambassador Java para llamadas salientes
import java.net.http.*;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

public class AmbassadorClient {

    private final HttpClient client;
    private final String targetUrl;
    private final ConcurrentHashMap<String, RequestStats> stats = new ConcurrentHashMap<>();
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final int failureThreshold = 5;
    private volatile boolean circuitOpen = false;
    private volatile long lastFailureTime = 0;
    private final long recoveryTimeoutMs = 30000;

    public AmbassadorClient(String targetUrl) {
        this.targetUrl = targetUrl;
        this.client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    }

    public HttpResponse<String> request(String method, String path,
                                        String body, int maxRetries) throws Exception {
        checkCircuit();

        Exception lastError = null;
        for (int attempt = 0; attempt < maxRetries; attempt++) {
            long start = System.currentTimeMillis();
            try {
                var builder = HttpRequest.newBuilder()
                    .uri(URI.create(targetUrl + path))
                    .timeout(Duration.ofSeconds(10));

                switch (method) {
                    case "GET" -> builder.GET();
                    case "POST" -> builder.POST(HttpRequest.BodyPublishers.ofString(body));
                    case "PUT" -> builder.PUT(HttpRequest.BodyPublishers.ofString(body));
                    case "DELETE" -> builder.DELETE();
                }

                var response = client.send(builder.build(),
                    HttpResponse.BodyHandlers.ofString());

                long latency = System.currentTimeMillis() - start;
                if (response.statusCode() < 500) {
                    recordSuccess(method + " " + path, latency);
                    return response;
                }
                recordFailure(method + " " + path, latency);
                lastError = new RuntimeException("HTTP " + response.statusCode());
            } catch (Exception e) {
                long latency = System.currentTimeMillis() - start;
                lastError = e;
                recordFailure(method + " " + path, latency);
            }

            if (attempt < maxRetries - 1) {
                Thread.sleep((long) Math.pow(2, attempt) * 1000);
            }
        }

        lastFailureTime = System.currentTimeMillis();
        if (failureCount.get() >= failureThreshold) {
            circuitOpen = true;
        }

        throw new RuntimeException("Reintentos agotados", lastError);
    }

    private void checkCircuit() throws Exception {
        if (circuitOpen) {
            if (System.currentTimeMillis() - lastFailureTime > recoveryTimeoutMs) {
                circuitOpen = false;
                failureCount.set(0);
            } else {
                throw new RuntimeException("Circuit breaker abierto");
            }
        }
    }

    private void recordSuccess(String endpoint, long latencyMs) {
        failureCount.set(0);
        circuitOpen = false;
        stats.computeIfAbsent(endpoint, k -> new RequestStats())
             .record(latencyMs, true);
    }

    private void recordFailure(String endpoint, long latencyMs) {
        failureCount.incrementAndGet();
        stats.computeIfAbsent(endpoint, k -> new RequestStats())
             .record(latencyMs, false);
    }

    static class RequestStats {
        final AtomicLong count = new AtomicLong(0);
        final AtomicLong errors = new AtomicLong(0);
        final AtomicLong totalLatency = new AtomicLong(0);

        void record(long latencyMs, boolean success) {
            count.incrementAndGet();
            totalLatency.addAndGet(latencyMs);
            if (!success) errors.incrementAndGet();
        }
    }
}

Ojo con el orden: un 5xx se registra como fallo antes de que siga el bucle de reintentos. Solo se marca como éxito después de revisar el status — si no, un servicio que flapea mantiene el contador de fallos en cero y el circuito nunca abre.

Cómo Funciona

Cada request sigue el mismo pipeline: el cliente abre conexión con el ambassador, el ambassador aplica su cadena de políticas — primero el chequeo del circuito, después el bucle de reintentos, luego el timeout por intento — y reenvía la llamada upstream. Las respuestas vuelven por el mismo camino, con latencia y status anotados antes de que el cliente las vea.

El orden importa. Chequear el circuito antes de reintentar hace que el fallo sea barato: cuando el upstream está caído, el ambassador devuelve error en microsegundos en vez de dormir tres rondas de backoff. Y el pool compartido es donde el patrón se paga solo — diez clientes con veinte conexiones a Postgres cada uno se convierten en un único pool de cincuenta.

El costo es un salto de red extra y un componente más que operar. Cada llamada suma la latencia del proxy — un milisegundo o dos dentro del cluster, más si el ambassador es remoto — y heredás su disponibilidad: si el ambassador muere, todos los clientes detrás quedan a oscuras. Corrélo como Deployment con réplicas, o como DaemonSet por nodo si querés que el salto se quede local.

Ambassadors que ya corrés: Envoy y el proxy de Linkerd son ambassadors de tráfico entre servicios. PgBouncer y ProxySQL son ambassadors de conexiones a base de datos. HAProxy delante de una API legacy también lo es. El patrón es viejo — lo nuevo es el nombre.

Variantes

Ambassador como sidecar (in-process)

# in_process_ambassador.py — lógica ambassador embebida en el cliente
# Requiere: requests, time, AmbassadorError de ambassador_proxy.py
import requests
import time
from ambassador_proxy import AmbassadorError

class InProcessAmbassador:
    """Lógica ambassador embebida directamente en el proceso cliente.
    Sin contenedor separado — solo una clase wrapper."""

    def __init__(self, target_url, max_retries=3, timeout=10):
        self.target_url = target_url
        self.max_retries = max_retries
        self.timeout = timeout
        self._pool = requests.Session()
        # Configurar el pooling de conexiones
        adapter = requests.adapters.HTTPAdapter(
            pool_connections=10,
            pool_maxsize=100,
            max_retries=0  # Los reintentos los manejamos nosotros
        )
        self._pool.mount("https://", adapter)
        self._pool.mount("http://", adapter)

    def get(self, path, **kwargs):
        return self._request("GET", path, **kwargs)

    def post(self, path, **kwargs):
        return self._request("POST", path, **kwargs)

    def _request(self, method, path, **kwargs):
        for attempt in range(self.max_retries):
            try:
                resp = self._pool.request(
                    method,
                    f"{self.target_url}{path}",
                    timeout=self.timeout,
                    **kwargs
                )
                if resp.status_code < 500:
                    return resp
            except requests.RequestException:
                pass
            if attempt < self.max_retries - 1:
                time.sleep(2 ** attempt)
        raise AmbassadorError(f"Falló tras {self.max_retries} reintentos")

Esto queda a medio camino de la variante ambassador in-process: la misma cadena de políticas, sin salto extra, pero la config de pooling y reintentos vive en tu código y no en tu infra.

Ambassador para múltiples servicios externos

# multi_ambassador.py — un ambassador ruteando a varios servicios
# Requiere: AmbassadorProxy de ambassador_proxy.py
from ambassador_proxy import AmbassadorProxy

class MultiServiceAmbassador:
    """Un único ambassador que rutea a múltiples servicios externos."""

    def __init__(self):
        self.services = {}

    def register(self, name, url, config):
        self.services[name] = AmbassadorProxy(url)
        self.services[name].failure_threshold = config.get("failure_threshold", 5)

    def call(self, service, method, path, **kwargs):
        if service not in self.services:
            raise ValueError(f"Servicio desconocido: {service}")
        return self.services[service].request(method, path, **kwargs)

    def all_stats(self):
        return {
            name: proxy.get_stats()
            for name, proxy in self.services.items()
        }

# Uso
ambassador = MultiServiceAmbassador()
ambassador.register("payment", "https://api.payment.com", {"failure_threshold": 3})
ambassador.register("shipping", "https://api.shipping.com", {"failure_threshold": 5})
ambassador.register("inventory", "https://api.inventory.com", {"failure_threshold": 10})

resp = ambassador.call("payment", "POST", "/charge", json={"amount": 99.99})

Qué Funciona

  • Usá pooling de conexiones — el ambassador mantiene conexiones persistentes con los servicios externos.
  • Configurá timeouts a dos niveles — por intento y de punta a punta — o los reintentos convierten un fallo de 5s en una espera de 30s.
  • Implementá circuit breaking — dejá de llamar a un servicio caído para que se recupere.
  • Recolectá métricas — cantidad de requests, tasa de errores y latencia por endpoint.
  • Usá backoff exponencial en los reintentos — 1s, 2s, 4s, no intervalos fijos.
  • Mantené el ambassador sin estado — es un proxy, no una caché ni un session store.
  • Monitoreá el ambassador mismo — si falla, caen todos los clientes detrás.
  • Preferí proxies estándar — Envoy, Nginx y HAProxy le ganan al código de proxy artesanal.

Errores Comunes

  • Sin circuit breaking: el ambassador sigue pegándole a un servicio muerto y empeora la caída.
  • Reintentar operaciones no idempotentes: reintentar un POST puede crear duplicados. Solo reintentá GET, PUT, DELETE.
  • Reintentos sin backoff: tres reintentos instantáneos son una thundering herd, no resiliencia. Usá backoff exponencial.
  • Un ambassador para todo: una sola instancia para todos los clientes es cuello de botella y punto único de fallo. Escalalo horizontalmente.
  • Ambassador a ciegas: sin health checks ni métricas del propio proxy, te enterás de que está caído por los logs de error de tus clientes.

Lecturas Adicionales

Preguntas frecuentes

¿Qué es el patrón ambassador?

Un proxy ubicado entre un cliente y los servicios externos. El ambassador resuelve los aspectos transversales — pooling de conexiones, reintentos, circuit breaking, monitorización, TLS — mientras el cliente le habla como si fuera el servicio real.

¿En qué se diferencia ambassador de sidecar?

Un sidecar corre en el mismo pod que su cliente, uno por instancia. Un ambassador puede correr como proceso o contenedor separado y ser compartido por muchos clientes. Los sidecars van co-ubicados; los ambassadors pueden ser remotos — aunque los términos se difuminan, porque la gente despliega ambassadors como sidecars todo el tiempo.

¿Cuándo conviene un ambassador en vez de una librería de cliente?

Cuando el cliente no se puede modificar (software legacy o de un vendor), cuando varios clientes deberían compartir pools de conexiones, o cuando querés centralizar las políticas transversales entre lenguajes. Si tu cliente es moderno y acepta librerías, la librería es más simple.

¿Puede el ambassador ser un punto único de fallo?

Sí, si corrés una sola instancia. Levantá varias detrás de un load balancer, o usá la variante sidecar donde cada pod tiene la suya.

¿Qué protocolos soporta el ambassador?

Los que soporte el proxy. Envoy y Nginx manejan HTTP, gRPC, TCP y TLS. Para protocolos de base de datos, usá proxies especializados como PgBouncer (PostgreSQL) o ProxySQL (MySQL).