Patrón Ambassador: Llamadas Resilientes vía Proxy Local
Descarga reintentos, timeouts y circuit breaker a un ambassador local que envuelve las llamadas remotas, manteniendo clientes delgados y políticas consistentes.
Visión General
Tu servicio de checkout llama a la API de precios. También lo hacen el carrito, el motor de recomendaciones y otras cuatro cosas. Cada uno creció con su propio bucle de reintentos, su propia constante de timeout y su propia costumbre de “loguear el fallo en algún sitio” — y ninguno coincide con los demás. Cuando la API de precios se degrada, obtienes cinco modos de fallo distintos y un ingeniero de guardia leyendo cinco codebases.
El patrón Ambassador pone todo eso en un solo lugar: un objeto helper local que envuelve el servicio remoto tras la misma interfaz que el cliente ya espera. Reintentos, timeouts, circuit breaker y métricas viven en el ambassador; el cliente solo llama a getUser(id) y nunca se entera de que hay una red de por medio.
Esta página cubre el ambassador como componente del lado del cliente: una librería u objeto en proceso que controlas. Para la variante de infraestructura donde el ambassador es un contenedor aparte o un sidecar proxy, mira el patrón Ambassador arquitectónico.
Cuándo Usarlo
- Varios clientes llaman al mismo servicio remoto y cada uno reimplementa la resiliencia; el ambassador la centraliza.
- Un servicio legacy o de terceros necesita traducción, caché o protección que el cliente no debería conocer.
- Restricciones de lenguaje o plataforma descartan un sidecar proxy: necesitas la lógica dentro del proceso.
- Quieres las políticas de resiliencia versionadas y testeadas como una unidad, en lugar de dispersas entre los llamadores.
Cuándo No Usarlo
- Un cliente, un servicio remoto, llamadas simples: un cliente HTTP con timeout basta.
- El objetivo es enforcement de políticas a nivel de cluster: eso es un service mesh (Istio, Linkerd) o la variante sidecar, no un wrapper en proceso.
- El “ambassador” es en realidad lógica de negocio: reglas de precios, validación u orquestación pertenecen al cliente o al servicio, no al proxy.
- El presupuesto de latencia es extremadamente ajustado: cada salto añade overhead; mide antes de envolver.
Solución
El ambassador implementa la interfaz del servicio, así que cambiar un cliente directo por un ambassador es invisible para los llamadores. Las políticas de resiliencia se aplican en orden estricto: primero el chequeo del circuit (fail fast cuando el servicio está caído), luego el bucle de reintentos, luego el timeout por intento.
Ambassador con reintentos, timeout y circuit breaker
// ambassador/ResilientUserService.ts
interface UserService {
getUser(id: string): Promise<{ id: string; name: string }>;
}
interface Order {
id: string;
userId: string;
user?: { id: string; name: string };
}
class RemoteUserService implements UserService {
async getUser(id: string) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
}
type CircuitState = 'closed' | 'open' | 'half-open';
class UserServiceAmbassador implements UserService {
private state: CircuitState = 'closed';
private failureCount = 0;
private openedAt = 0;
private readonly failureThreshold = 5;
private readonly retryCount = 3;
private readonly timeoutMs = 2000;
private readonly resetAfterMs = 30_000;
constructor(private remote: UserService) {}
async getUser(id: string) {
if (this.state === 'open') {
// Tras el enfriamiento, deja pasar exactamente una llamada de prueba
if (Date.now() - this.openedAt >= this.resetAfterMs) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is open');
}
}
for (let attempt = 0; attempt < this.retryCount; attempt++) {
try {
const result = await this.callWithTimeout(id);
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
if (attempt < this.retryCount - 1) {
// Backoff exponencial real: 1s, 2s, 4s — más jitter para evitar
// que todos los clientes reintenten a la vez tras una caída
const backoff = 1000 * 2 ** attempt;
await this.delay(backoff + Math.random() * 500);
}
}
}
throw new Error('Unreachable');
}
private async callWithTimeout(id: string) {
return Promise.race([
this.remote.getUser(id),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), this.timeoutMs)
),
]);
}
private onSuccess(): void {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failureCount++;
if (this.state === 'half-open' || this.failureCount >= this.failureThreshold) {
this.state = 'open';
this.openedAt = Date.now();
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// El cliente solo ve UserService — el ambassador es transparente
class OrderService {
constructor(private users: UserService) {}
async getOrderWithUser(orderId: string): Promise<Order> {
const order: Order = { id: orderId, userId: 'user-123' };
order.user = await this.users.getUser(order.userId);
return order;
}
}
Dos detalles importan aquí. El backoff es exponencial de verdad (2^attempt más jitter aleatorio) porque los reintentos sincronizados tras una caída crean un thundering herd que mantiene el servicio abajo. Y el circuit breaker tiene un estado half-open real: tras el enfriamiento, una llamada de prueba pasa; si falla, el circuit se reabre inmediatamente en lugar de golpear el servicio con una tormenta completa de reintentos.
Variación: Ambassador de monitorización
// ambassador/MonitoringAmbassador.ts
class MonitoringAmbassador implements UserService {
private requestCount = 0;
private errorCount = 0;
private totalLatency = 0;
constructor(private remote: UserService) {}
async getUser(id: string) {
const start = Date.now();
this.requestCount++;
try {
const result = await this.remote.getUser(id);
this.totalLatency += Date.now() - start;
return result;
} catch (error) {
this.errorCount++;
throw error;
}
}
getMetrics() {
return {
requests: this.requestCount,
errors: this.errorCount,
avgLatency: this.requestCount > 0
? this.totalLatency / this.requestCount
: 0,
};
}
}
Los ambassadors componen: MonitoringAmbassador(UserServiceAmbassador(remote)) te da métricas alrededor de la ruta resiliente, en ese orden; el monitor ve resultados finales, no el ruido por intento.
Escenario: Ambassador para un servicio legacy SOAP
Servicio moderno -> Ambassador -> Servicio legacy SOAP
El ambassador traduce REST/JSON a SOAP/XML, reintenta con
backoff, aplica circuit breaker, cachea respuestas y expone métricas.
// ambassador/LegacyAmbassador.ts
class LegacyAmbassador {
private breaker = new CircuitBreaker({ failureThreshold: 5, resetAfterMs: 30_000 });
private cache = new Map<string, { data: unknown; expiry: number }>();
private readonly maxRetries = 3;
private readonly baseDelayMs = 1000;
constructor(private legacyEndpoint: string) {}
async callLegacy(method: string, params: unknown): Promise<unknown> {
if (!this.breaker.canExecute()) {
throw new Error('Circuit open: legacy service unavailable');
}
const cacheKey = `${method}:${JSON.stringify(params)}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiry > Date.now()) return cached.data;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
const result = await this.callSOAP(method, params);
this.breaker.recordSuccess();
this.cache.set(cacheKey, { data: result, expiry: Date.now() + 60_000 });
return result;
} catch {
this.breaker.recordFailure();
if (attempt < this.maxRetries - 1) {
await new Promise(r =>
setTimeout(r, this.baseDelayMs * 2 ** attempt)
);
}
}
}
throw new Error('Legacy service failed after retries');
}
private async callSOAP(method: string, params: unknown): Promise<unknown> {
const envelope = this.jsonToSOAP(method, params);
const response = await fetch(this.legacyEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'text/xml' },
body: envelope,
});
return this.soapToJSON(await response.text());
}
private jsonToSOAP(method: string, params: unknown): string {
return `<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><${method}>${JSON.stringify(params)}</${method}></soap:Body>
</soap:Envelope>`;
}
private soapToJSON(xml: string): unknown {
return JSON.parse(xml.match(/<return>(.*)<\/return>/s)?.[1] ?? '{}');
}
}
El servicio moderno nunca se entera de que habla con SOAP: traducción, reintentos y protección quedan en el ambassador. Aquí es donde el patrón se gana el sueldo: aísla una interfaz mala detrás de una buena, en espíritu similar a la capa anticorrupción pero centrado en el transporte en lugar de en la traducción del modelo de dominio. Una advertencia que el sketch oculta: CircuitBreaker aquí es una clase pequeña con canExecute(), recordSuccess() y recordFailure() — la misma máquina de estados del ejemplo principal, extraída para que la ruta legacy no lleve la lógica de resiliencia en línea.
Cómo Funciona
- El servicio remoto provee la lógica de negocio por la red.
- El ambassador implementa la misma interfaz, envolviendo cada llamada con resiliencia y observabilidad.
- El cliente llama al ambassador exactamente como llamaría al servicio, sin cambios.
- Las políticas (reintentos, timeouts, circuit breaker, caché) viven en un solo lugar y se aplican uniformemente a cada llamador.
El orden de operaciones es deliberado: chequeo del circuit → bucle de reintentos → timeout por intento. Un timeout dentro del bucle acota cada intento; el circuit breaker fuera acota el desastre completo. Invierte el orden y los reintentos siguen martilleando un servicio muerto mientras el circuit aún está contando.
Coste y límites
Cada salto cuesta algo. El ambassador añade su propia latencia, normalmente microsegundos para un wrapper en proceso, pero un salto de sidecar puede sumar un milisegundo o dos, y un reintento en p99 triplica la latencia de cola por diseño. Establece un presupuesto de reintentos: no más del N% del total de peticiones puede ser reintento, o los propios reintentos se convierten en la carga que mata al servicio. El ambassador también puede volverse el cuello de botella si poola mal las conexiones o guarda estado por petición que no es thread-safe — mantenlo stateless y mídelo como a cualquier otra dependencia. Cuando el fallo del propio ambassador tumbaría al cliente, decide qué fallo prefieres: llamar directo o fallar cerrado.
Dónde Aparece
- Interceptores de gRPC: deadlines, reintentos y auth inyectados alrededor de cada llamada; ambassador de manual.
- Sidecars Envoy / Istio: la versión en contenedor de la misma idea; mira el patrón sidecar.
- Polly (.NET) y resilience4j (Java): librerías que envuelven llamadas con políticas de retry + circuit breaker, es decir, ambassador-como-librería.
- Drivers de base de datos y SDKs: la mayoría de SDKs cloud embeben un ambassador en silencio: reintentos, throttling y métricas alrededor de la llamada de red.
Qué Funciona
- Mantén el ambassador stateless — el estado del circuit por instancia está bien; cualquier otra cosa debería vivir en infraestructura compartida (Redis, backend de métricas) para que las réplicas coincidan.
- Distingue errores reintentables de fatales — reintenta un timeout o un 503; nunca un 400 o un fallo de auth. El patrón de reintentos cubre la taxonomía.
- Jitter en cada backoff —
2^nsin jitter es cómo nacen las tormentas de reintentos. - Fallo rápido con circuit abierto — el trabajo del ambassador es proteger el servicio remoto, no encolar demanda contra él.
- Propaga la cancelación — si el llamador se cancela, el ambassador debe abortar los reintentos en vuelo, no terminarlos.
Errores Comunes
- Lógica de negocio filtrándose — el ambassador traduce y protege; no decide. Si sabe lo que significa un precio, ya no es un ambassador.
- Reintentar operaciones no idempotentes — un
POSTque cobra una tarjeta no puede reintentarse a ciegas; combina reintentos con claves de idempotencia. - Backoff lineal etiquetado como “exponencial” —
1000 * attemptcrece 1s, 2s, 3s. El exponencial real es2^n, y el jitter importa más que la curva. - Circuit breaker que nunca prueba — open→closed por temporizador sin llamada de prueba half-open se reabre con la primera petición real de todos modos.
- Ambassador por defecto — si un cliente llama a un servicio, el wrapper es complejidad accidental. Añádelo cuando aparezca el segundo llamador o las políticas diverjan.
Lecturas Adicionales
- Azure Architecture Center: patrón Ambassador — la entrada de catálogo canónica que esta página implementa.
- Documentación de gRPC: deadlines e interceptores — el ambassador integrado del mundo gRPC.
- Documentación de Istio: gestión de tráfico — adónde va el patrón cuando se convierte en infraestructura.
- Ejemplos companion — ambassador ejecutable con retry, circuit breaker y métricas en Python y JavaScript con tests.
Preguntas frecuentes
¿En qué se diferencia el patrón Ambassador de Proxy?
Un Proxy controla el acceso a un objeto — carga perezosa, control de acceso, caché. El ambassador es una especialización para llamadas remotas: añade resiliencia de red (reintentos, timeouts, circuit breaker) y típicamente vive del lado del cliente como librería o proceso local. Todo ambassador es un proxy; no todo proxy maneja modos de fallo remotos.
Ambassador vs Sidecar — ¿cuál uso?
Ambassador vive dentro de tu proceso y habla tu lenguaje — elígelo cuando necesites control en el punto de llamada, interfaces tipadas o no puedas correr contenedores extra. Sidecar vive junto a tu servicio en el pod e intercepta la red — elígelo para políticas agnósticas de lenguaje a nivel de cluster. Componen: una librería ambassador para lógica consciente del negocio más un sidecar para preocupaciones de transporte como mTLS.
¿Puedo usarlo con gRPC?
Sí — los interceptores de gRPC son ambassadors integrados en el framework. Envuelven llamadas con deadlines, reintentos y auth sin tocar el cliente. Si estás en gRPC, usa interceptores en lugar de escribir tu propio wrapper.
¿Cuándo reemplaza un service mesh al ambassador?
Cuando cada servicio necesita las mismas políticas y quieres imponerlas centralmente — reintentos, timeouts y mTLS vía configuración de Istio/Linkerd en lugar de código. El ambassador sigue ganando para lógica específica del cliente: presupuestos por llamador, caché indexada por argumentos, traducción de protocolos legacy.
¿El circuit breaker debe vivir en el ambassador o en un store compartido?
En el ambassador para protección por instancia — protege tu proceso de martillear un servicio muerto. En infraestructura compartida (Redis) si necesitas que todas las réplicas disparen a la vez, pero eso añade una llamada de red a la ruta de fallo, que suele ser peor trato que breakers locales independientes.
¿Qué pasa con las peticiones encoladas cuando el circuit se abre?
Fallan rápido — ese es el punto. El llamador recibe un error inmediato y decide: servir caché vieja, degradar la funcionalidad o propagar. Lo que el ambassador jamás debe hacer es retener peticiones esperando a que el circuit cierre; eso convierte una caída de servicio en agotamiento del thread-pool de tu lado.
Recursos Relacionados
Patrón Circuit Breaker en Python, JavaScript y Java
Previene fallos en cascada deteniendo solicitudes a servicios que están fallando. Un patrón arquitectural para sistemas distribuidos resilientes.
PatternPatrón Proxy
Proporciona un sustituto o marcador de posición para otro objeto para controlar el acceso a él. Un patrón estructural para control de acceso, carga perezosa y logging.
PatternPatrón Sidecar: Extendé Services con Companion Containers
Cómo extendear services con companion containers para cross-cutting concerns. Cubre sidecar containers, shared volumes, health probes, y service mesh sidecars.
PatternPatrón Retry
Reintenta una operación que ha fallado con errores transitorios, usando estrategias configurables como delay fijo, backoff exponencial o integración con circuit breaker.
PatternPatró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.
GuideArquitectura de Microservicios — Cuándo Usarla y Cuándo No
Guía práctica de microservicios: beneficios, trade-offs, patrones comunes y cuándo elegirlos sobre monolitos. Cubre estrategias de descomposición y complejidad operativa.