Este é um case de resiliência: o payment-service simula um memory leak real (não latência/erro artificial) que cresce até o limite de memória do container, entra em crash-loop (OOM-kill + restart automático) sob carga contínua, e o order-service foi evoluindo, branch a branch, para lidar com isso — retry com backoff exponencial e jitter, circuit breaker e bulkhead.
Este é um case de resiliência: o payment-service simula um memory leak real que cresce até o limite de memória do container, entra em crash-loop (OOM-kill + restart automático) sob carga contínua, e o order-service foi evoluindo, branch a branch, para lidar com isso: retry com backoff exponencial e jitter, circuit breaker e bulkhead.
Código Fonte e Relatórios de Carga: Repositório completo no GitHub contendo as 4 branches evolutivas, os scripts do k6 e instruções do Docker Compose:
🔗 github.com/marcelo3macedo/node-microservices-resilience-patterns
O ponto de partida do case é a branch feature/01-base-service-unstable: o k6 dispara requisições de carga contra o order-service, que por sua vez consulta o payment-service para confirmar cada pedido.
Só que o payment-service é um serviço instável, com um memory leak real embutido de propósito. Cada requisição que ele recebe aloca e retém um buffer, o RSS (memória física) do processo cresce continuamente sob carga até encostar no limite de memória do container (256MB), o Docker mata o processo por OOM (exit code 137).
A RAM sobe quase linearmente até ~250MB, despenca em torno de t+31s (OOM-kill + restart), e sobe de novo até o fim do teste.
O order-service apenas repassa a chamada ao payment-service com um timeout de 3s, sem nenhuma proteção.
O resultado sob carga: 286 requisições, 16,43% de erro, p(95) de 3004,54ms , o timeout sendo atingido em quase 1 a cada 6 pedidos, concentrados exatamente na janela em que o payment-service está com a memória no limite.
Situação Inicial:
Arquitetura Final:
O order-service é o gateway que chama o payment-service de forma síncrona via HTTP. Ao longo de quatro branches, ele foi ganhando camadas de proteção:
| Branch | O que foi adicionado |
|---|---|
feature/01-base-service-unstable | Cenário base: payment-service com memory leak real, sem nenhuma proteção |
feature/02-pattern-retry-backoff | Retry com backoff exponencial + jitter, e restart: on-failure no payment-service |
feature/03-pattern-circuit-breaker | Circuit breaker: falha rápido quando o payment-service está degradado |
feature/04-pattern-bulkhead-isolation | Bulkhead: limite de concorrência simultânea, isolando o event loop |
Informações detalhada sobre as principais decisões deste case:
| Decisão | Alternativa Considerada | Por que foi Escolhida? | Trade-off Aceito |
|---|---|---|---|
| Comunicação Síncrona HTTP (com Retry + Circuit Breaker + Bulkhead) | Mensageria Assíncrona (SQS/RabbitMQ + Outbox Pattern) | Isolar e mensurar os padrões de resiliência em fluxos síncronos, sem a camada de consistência eventual de filas. | Ausência de garantia de entrega de 100%. Indisponibilidade gera falha rápida (fail-fast) em vez de reprocessamento diferido. |
| Janela do Circuit Breaker em 1500ms (Reduzida dos 3000ms padrão) | Manter a janela padrão de 3000ms | O container do payment-service se recupera em ~1-2s após o restart. A janela menor transita para Half-Open mais rápido. | Leve risco de disparar uma requisição de teste (Half-Open) enquanto o serviço de destino ainda finaliza a inicialização. |
| Retry limitado a 3 tentativas (Teto de 1000ms no Backoff) | Mais tentativas / Teto de backoff maior | Evitar retenção prolongada do cliente no Event Loop durante cenários de Crash-Loop no serviço. | Requisições que se recuperariam em uma 4ª tentativa falham antecipadamente. |
Ao aplicar Circuit breaker, por que a taxa de erro subiu de 2,58% para 7,84%?
Ao analisar os dados da Seção Métricas e Resultados, nota-se que a taxa de erro absoluta aumentou dos branches iniciais (2,58% no retry+backoff) para os mais avançados (7,84% no circuit-breaker e 7,00% no bulkhead).
Isso não é uma regressão, mas sim o comportamento esperado dos padrões Circuit Breaker e Bulkhead:
order-service), a arquitetura opta por rejeitar chamadas rapidamente.Em sistemas de alta disponibilidade, falhar rápido para proteger a saúde global do cluster é preferível a tentar salvar requisições individuais a qualquer custo.
order-service/utils/retry.jsfeature/02-pattern-retry-backoffO jitter evita que várias requisições retentem exatamente no mesmo instante contra um serviço que acabou de voltar:
function backoffDelay(attempt, baseDelayMs, maxDelayMs) {
const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
return Math.random() * exponential;
}
if (response.ok || !isRetryableStatus(response.status) || isLastAttempt) {
return response;
}
const delay = backoffDelay(attempt, baseDelayMs, maxDelayMs);
await sleep(delay);
order-service/utils/circuit-breaker.jsfeature/03-pattern-circuit-breakerA máquina de estados CLOSED → OPEN → HALF_OPEN é a "mágica" do padrão: enquanto aberto, canAttempt() corta a chamada sem sequer tocar o payment-service:
canAttempt() {
if (this.state !== STATE.OPEN) return true;
if (Date.now() - this.openedAt >= this.openDurationMs) {
this.state = STATE.HALF_OPEN;
return true;
}
return false;
}
onFailure() {
this.failureCount += 1;
const shouldOpen = this.state === STATE.HALF_OPEN || this.failureCount >= this.failureThreshold;
if (shouldOpen) {
this.state = STATE.OPEN;
this.openedAt = Date.now();
this.failureCount = 0;
}
}
order-service/utils/bulkhead.jsfeature/04-pattern-bulkhead-isolationUm contador simples de chamadas ativas; sem fila configurada, o excesso é rejeitado na hora em vez de esperar:
async run(fn) {
if (this.active >= this.maxConcurrent) {
if (this.queue.length >= this.maxQueue) {
throw new BulkheadRejectedError(
`Bulkhead "${this.name}" cheio (${this.active}/${this.maxConcurrent} em execução, ${this.queue.length}/${this.maxQueue} na fila)`
);
}
await new Promise((resolve) => this.queue.push(resolve));
}
this.active += 1;
try { return await fn(); } finally { this.active -= 1; }
}
Números de cada execução (uma por branch), estão detalhados em cada branch nos arquivos results/report.txt e results/k6-summary.json.
| Branch | Requisições (req/s) | Erro % | p50 | p90 | p95 | Máx |
|---|---|---|---|---|---|---|
| Base | 286 (8,16) | 16,43% | 33,11ms | 3002,77ms | 3004,54ms | 3034,73ms |
| Retry | 388 (11,03) | 2,58% | 16,70ms | 467,80ms | 1148,36ms | 2432,52ms |
| Circuit Breaker | 459 (13,00) | 7,84% | 11,73ms | 196,96ms | 399,79ms | 1370,63ms |
| Bulkhead | 443 (12,56) | 7,00% | 16,08ms | 105,03ms | 204,89ms | 2561,69ms |
Na última branch, com os três padrões ativos ao mesmo tempo, dá para ver exatamente qual mecanismo interceptou cada tipo de degradação:
payment-service reiniciado. O retry sozinho não salvaria uma indisponibilidade sustentada.payment-service esteja num momento ruim do ciclo de memória.503.payment-service, isso exigiria uma camada assíncrona (fila/outbox), fora do escopo deste case.O código completo, com os quatro branches, os READMEs congelados de cada etapa e os relatórios brutos de cada execução, está em: