Microserviços com Node.js: Comunicação, Resiliência e Observabilidade

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
Um serviço HTTP que chama outro serviço HTTP que chama outro serviço HTTP. Quando o terceiro demora 5 segundos para responder, o primeiro segura a conexão, o segundo acumula requests na fila, e o usuário vê um spinner eterno. Três serviços, um único ponto de falha cascateado.
Microserviços não resolvem problemas de complexidade: eles redistribuem a complexidade para a rede. E a rede falha. A questão não é SE um serviço vai ficar indisponível, mas QUANDO, e como o sistema se comporta nesse cenário.
Este post cobre três pilares que separam microserviços funcionais de microserviços frágeis: comunicação (síncrona e assíncrona), resiliência (circuit breaker, retry, timeout) e observabilidade (tracing distribuído com OpenTelemetry).
Comunicação síncrona vs assíncrona: quando usar cada uma
A escolha entre HTTP direto e mensageria define o acoplamento temporal entre serviços. Se o serviço A precisa da resposta do serviço B para continuar, a comunicação é síncrona. Se o serviço A dispara um evento e segue em frente, é assíncrona.
| Critério | HTTP síncrono | Mensageria assíncrona |
|---|---|---|
| Latência percebida | Soma das latências de toda a cadeia | Resposta imediata ao cliente, processamento em background |
| Acoplamento temporal | Alto: se B cai, A falha | Baixo: se B cai, a mensagem espera na fila |
| Complexidade de debug | Menor (request/response linear) | Maior (mensagens em filas, ordem não garantida, dead letter queues) |
| Consistência de dados | Mais fácil de garantir por transação | Eventual consistency, exige idempotência |
| Caso de uso típico | Consulta de dados em tempo real, validação | Processamento de pedidos, envio de e-mails, geração de relatórios |
Se o usuário precisa ver o resultado na mesma request (consulta de saldo, validação de CPF), use HTTP. Se o resultado pode chegar depois (confirmação de pagamento, geração de PDF), use mensageria. Já cobrimos o lado assíncrono em detalhes no post sobre mensageria com BullMQ e Redis.
Chamadas HTTP entre serviços com timeout e retry
Uma chamada HTTP entre serviços sem timeout é uma bomba-relógio. O default do Node.js (runtime que usa libuv para I/O) para http.request não tem timeout de resposta: ele espera indefinidamente. Isso significa que um serviço lento trava quem o chama.
// src/infra/http-client.ts
import { setTimeout } from "node:timers/promises";
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
timeoutMs: number;
}
const DEFAULT_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 200,
timeoutMs: 3000,
};
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
config: RetryConfig = DEFAULT_CONFIG
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
const controller = new AbortController();
// Timeout via AbortController: se o serviço não responder
// em timeoutMs, a request é cancelada no nível de socket
const timeoutId = globalThis.setTimeout(
() => controller.abort(),
config.timeoutMs
);
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Retry apenas em erros de servidor (5xx), não em 4xx
// porque 4xx indica erro do chamador, não indisponibilidade
if (response.status >= 500 && attempt < config.maxRetries) {
lastError = new Error(`HTTP ${response.status} from ${url}`);
const delay = config.baseDelayMs * Math.pow(2, attempt);
await setTimeout(delay);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < config.maxRetries) {
// Exponential backoff: 200ms, 400ms, 800ms
// Evita thundering herd quando o serviço volta
const delay = config.baseDelayMs * Math.pow(2, attempt);
await setTimeout(delay);
}
}
}
throw lastError;
}O exponential backoff é obrigatório. Sem ele, quando um serviço volta de uma queda, recebe todas as retries de todos os clientes ao mesmo tempo e cai de novo.
Circuit breaker: pare de bater em serviço morto
Retry resolve falhas transitórias (um pacote perdido, um restart rápido). Circuit breaker resolve falhas prolongadas. Se um serviço está fora do ar há 30 segundos, não faz sentido continuar enviando requests que vão falhar: isso consome conexões, threads do event loop e piora a latência de todo o sistema.
O padrão tem três estados: closed (tráfego normal), open (todas as requests falham imediatamente sem chamar o serviço) e half-open (permite uma request de teste para verificar se o serviço voltou).
// src/infra/circuit-breaker.ts
type CircuitState = "closed" | "open" | "half-open";
interface CircuitBreakerConfig {
failureThreshold: number; // quantas falhas consecutivas abrem o circuito
resetTimeoutMs: number; // tempo no estado open antes de tentar half-open
successThreshold: number; // quantos sucessos em half-open fecham o circuito
}
export class CircuitBreaker {
private state: CircuitState = "closed";
private failureCount = 0;
private successCount = 0;
private lastFailureTime = 0;
constructor(
private readonly name: string,
private readonly config: CircuitBreakerConfig = {
failureThreshold: 5,
resetTimeoutMs: 30_000,
successThreshold: 2,
}
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed < this.config.resetTimeoutMs) {
// Fail fast: não gasta recursos chamando serviço que sabemos estar fora
throw new Error(
`Circuit ${this.name} is OPEN. Next attempt in ${this.config.resetTimeoutMs - elapsed}ms`
);
}
this.state = "half-open";
this.successCount = 0;
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
if (this.state === "half-open") {
this.successCount++;
// Exige N sucessos consecutivos antes de fechar,
// para não reabrir tráfego total com base em uma única request
if (this.successCount >= this.config.successThreshold) {
this.state = "closed";
this.failureCount = 0;
}
} else {
this.failureCount = 0;
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.config.failureThreshold) {
this.state = "open";
}
}
getState(): CircuitState {
return this.state;
}
}Integrando o circuit breaker com o HTTP client:
// src/services/order-service-client.ts
import { CircuitBreaker } from "../infra/circuit-breaker";
import { fetchWithRetry } from "../infra/http-client";
const orderCircuit = new CircuitBreaker("order-service", {
failureThreshold: 5,
resetTimeoutMs: 30_000,
successThreshold: 2,
});
export async function getOrder(orderId: string) {
return orderCircuit.execute(async () => {
const response = await fetchWithRetry(
`http://order-service:3001/orders/${orderId}`,
{ method: "GET" },
{ maxRetries: 2, baseDelayMs: 100, timeoutMs: 2000 }
);
if (!response.ok) {
throw new Error(`Order service returned ${response.status}`);
}
return response.json();
});
}O retry fica DENTRO do circuit breaker. Se as retries esgotam, o circuit breaker conta como falha. Após 5 falhas, o circuito abre e as próximas chamadas falham instantaneamente, sem consumir rede.
Para quem já estrutura serviços com API Gateway, o circuit breaker pode viver no gateway em vez de em cada serviço chamador.
Health checks que significam algo
Um endpoint /health que retorna 200 OK sem verificar nada é inútil. O Kubernetes (ou qualquer orquestrador) vai achar que o serviço está saudável enquanto ele não consegue se conectar ao banco.
// src/routes/health.ts
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { createClient } from "redis";
export async function healthRoutes(
app: FastifyInstance,
prisma: PrismaClient,
redis: ReturnType<typeof createClient>
) {
// Liveness: o processo está rodando e respondendo?
// Kubernetes usa isso para decidir se mata o container
app.get("/health/live", async () => {
return { status: "alive" };
});
// Readiness: o serviço consegue processar requests?
// Kubernetes usa isso para remover o pod do load balancer
app.get("/health/ready", async (request, reply) => {
const checks: Record<string, "ok" | "fail"> = {};
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = "ok";
} catch {
checks.database = "fail";
}
try {
await redis.ping();
checks.redis = "ok";
} catch {
checks.redis = "fail";
}
const allHealthy = Object.values(checks).every((v) => v === "ok");
reply.status(allHealthy ? 200 : 503).send({
status: allHealthy ? "ready" : "degraded",
checks,
});
});
}Separe liveness de readiness. Se o banco cai, o serviço não está pronto para receber tráfego (readiness fail), mas o processo ainda está vivo (liveness ok). Matar o container porque o banco caiu não resolve nada: o novo container também não vai conseguir conectar. Quem usa Fastify com Prisma já tem o PrismaClient disponível para esse tipo de check.
Observabilidade com OpenTelemetry: tracing distribuído
Logs dizem O QUE aconteceu. Métricas dizem QUANTO aconteceu. Traces dizem POR QUE demorou. Em microserviços, um trace distribuído conecta todas as chamadas de uma request do usuário, mesmo atravessando 5 serviços diferentes.
O OpenTelemetry (OTel) é o padrão de instrumentação. Ele coleta traces e envia para backends como Jaeger, Grafana Tempo ou Datadog.
// src/infra/tracing.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: "payment-service",
[ATTR_SERVICE_VERSION]: "1.2.0",
}),
traceExporter: new OTLPTraceExporter({
// Collector rodando no cluster, recebe traces via OTLP/HTTP
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://otel-collector:4318/v1/traces",
}),
instrumentations: [
getNodeAutoInstrumentations({
// Desabilita instrumentação de fs porque gera spans demais
// sem valor diagnóstico em serviços HTTP
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
sdk.start();
// Garante flush dos traces pendentes antes do processo morrer
process.on("SIGTERM", async () => {
await sdk.shutdown();
process.exit(0);
});Esse arquivo precisa ser importado ANTES de qualquer outro módulo. A auto-instrumentação do OTel faz monkey-patch em http, fetch, drivers de banco e clientes Redis. Se você importar o driver do Postgres antes do OTel inicializar, o trace não captura as queries.
# Inicie o serviço com o tracing carregado primeiro
node --require ./dist/infra/tracing.js ./dist/server.jsPara criar spans customizados em operações de negócio:
// src/services/payment-processor.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("payment-processor");
export async function processPayment(orderId: string, amount: number) {
return tracer.startActiveSpan("process-payment", async (span) => {
// Atributos do span aparecem no Jaeger/Tempo como metadados pesquisáveis
span.setAttribute("order.id", orderId);
span.setAttribute("payment.amount", amount);
try {
const gateway = await chargeGateway(orderId, amount);
span.setAttribute("payment.gateway_id", gateway.transactionId);
span.setStatus({ code: SpanStatusCode.OK });
return gateway;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}Quando o payment-service chama o order-service via HTTP, o header traceparent propaga automaticamente (a auto-instrumentação injeta). No Jaeger, você vê o trace completo: request do usuário, chamada ao payment-service, chamada ao order-service, query no banco, tudo com latência de cada etapa.
Para quem roda serviços em containers, o guia de Docker para devs mostra como configurar o collector como sidecar ou serviço no compose.
Anti-patterns: o que NÃO fazer
Chamadas síncronas em cascata sem fallback
Código errado:
// ERRADO: três chamadas síncronas encadeadas sem timeout, sem retry, sem fallback
async function createOrder(userId: string, items: CartItem[]) {
const user = await fetch(`http://user-service/users/${userId}`).then(r => r.json());
const inventory = await fetch(`http://inventory-service/check`, {
method: "POST",
body: JSON.stringify({ items }),
}).then(r => r.json());
const payment = await fetch(`http://payment-service/charge`, {
method: "POST",
body: JSON.stringify({ userId, amount: inventory.total }),
}).then(r => r.json());
return { user, inventory, payment };
}Problemas: sem timeout (qualquer serviço lento trava tudo), sem retry (falha transitória mata a operação), sem circuit breaker (serviço fora do ar recebe requests infinitamente), sem tratamento de erro (um 500 do inventory-service vira exceção não tratada).
Código correto:
// CORRETO: circuit breaker por serviço, timeout, retry, fallback para dados não-críticos
import { CircuitBreaker } from "../infra/circuit-breaker";
import { fetchWithRetry } from "../infra/http-client";
const userCircuit = new CircuitBreaker("user-service");
const inventoryCircuit = new CircuitBreaker("inventory-service");
const paymentCircuit = new CircuitBreaker("payment-service");
async function createOrder(userId: string, items: CartItem[]) {
// Dados do usuário podem vir de cache se o serviço estiver fora
const user = await userCircuit
.execute(() =>
fetchWithRetry(`http://user-service:3002/users/${userId}`)
.then((r) => r.json())
)
.catch(() => getCachedUser(userId));
// Inventário é crítico: se falha, a ordem não pode ser criada
const inventory = await inventoryCircuit.execute(() =>
fetchWithRetry(
`http://inventory-service:3003/check`,
{ method: "POST", body: JSON.stringify({ items }), headers: { "Content-Type": "application/json" } },
{ maxRetries: 2, baseDelayMs: 100, timeoutMs: 2000 }
).then((r) => {
if (!r.ok) throw new Error(`Inventory check failed: ${r.status}`);
return r.json();
})
);
// Pagamento via mensageria: dispara e confirma depois
await publishToQueue("payment.process", {
userId,
orderId: generateOrderId(),
amount: inventory.total,
});
return { status: "processing" };
}A diferença: o user-service tem fallback para cache (dado não-crítico para a operação). O inventory-service falha explicitamente (dado crítico). O pagamento sai da cadeia síncrona e vai para uma fila, como detalhado no post sobre BullMQ.
Logs sem correlation ID
Sem um identificador que conecte logs de diferentes serviços, debugar um problema em produção com 10 serviços é procurar agulha em palheiro. O traceparent do OpenTelemetry resolve isso, mas se você não usa OTel, propague pelo menos um x-request-id:
// src/plugins/request-id.ts
import { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
export async function requestIdPlugin(app: FastifyInstance) {
app.addHook("onRequest", (request, reply, done) => {
// Preserva o ID se veio de outro serviço upstream,
// senão gera um novo na borda do sistema
const requestId = (request.headers["x-request-id"] as string) || randomUUID();
request.headers["x-request-id"] = requestId;
reply.header("x-request-id", requestId);
done();
});
}Quem estrutura projetos seguindo Clean Architecture com TypeScript pode colocar esse plugin na camada de infra, separado da lógica de domínio.
Quando microserviços NÃO são a resposta
Se o time tem menos de 5 devs, se o domínio ainda não está estabilizado, ou se a latência entre serviços é inaceitável para o caso de uso, um monolito modular é a escolha certa. Microserviços adicionam complexidade operacional real: deploy independente exige CI/CD por serviço (estratégias de deploy), observabilidade distribuída exige infraestrutura própria, e migrations coordenadas entre serviços que compartilham dados são significativamente mais difíceis.
A regra que funciona: comece com um monolito bem modularizado. Extraia para serviço separado apenas o módulo que tem requisito de escala, deploy ou tecnologia diferente do resto. Extrair o módulo de processamento de imagens que precisa de GPU faz sentido. Extrair o CRUD de usuários para um serviço separado porque "microserviços são o futuro" não faz.
FAQ
Preciso de Kubernetes para rodar microserviços?
Não. Docker Compose resolve para 3-5 serviços em times pequenos. Kubernetes faz sentido quando você precisa de auto-scaling por serviço, self-healing automático e gerenciamento de dezenas de serviços. O custo operacional do Kubernetes é alto: se você não tem alguém dedicado a infra, considere plataformas gerenciadas (ECS, Cloud Run, Fly.io).
gRPC ou REST para comunicação entre serviços?
Se os dois serviços são internos e a latência importa, gRPC. O protocolo binário (Protocol Buffers) serializa mais rápido que JSON, e o HTTP/2 multiplexado reduz overhead de conexão. Se um dos consumidores é externo ou você precisa de debugging fácil com curl, REST. A maioria dos sistemas funciona bem com REST interno até o ponto em que a serialização JSON vira gargalo mensurável.
Como lidar com transações distribuídas?
Evite. Se dois serviços precisam de consistência forte na mesma operação, provavelmente pertencem ao mesmo serviço. Quando não é possível unificar, use o padrão Saga: cada serviço executa sua parte e publica um evento. Se uma etapa falha, os serviços anteriores executam compensações (estorno, liberação de estoque). Sagas são complexas de implementar e debugar, o que reforça o ponto: não distribua o que não precisa ser distribuído.
Qual a diferença entre observabilidade e monitoramento?
Monitoramento responde perguntas conhecidas ("o CPU está acima de 80%?"). Observabilidade permite investigar perguntas que você não previu ("por que requests do usuário X demoram 3x mais que a média?"). Traces distribuídos, logs estruturados com correlation ID e métricas de latência por endpoint formam a base de observabilidade. Sem os três, você está no escuro quando o problema não é óbvio.
OpenTelemetry substitui o Datadog/New Relic?
O OpenTelemetry é o padrão de coleta e instrumentação. Datadog e New Relic são backends de armazenamento e visualização. Você pode usar OTel para coletar e enviar para qualquer backend (Jaeger, Grafana Tempo, Datadog, New Relic). A vantagem: se você trocar de backend amanhã, a instrumentação no código não muda. A instrumentação é vendor-neutral. O lock-in fica apenas no backend de visualização, que é mais fácil de migrar.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


