Microserviços
O que muda quando a chamada atravessa a rede: prazo, retry seguro, disjuntor, descoberta e propagação do rastro.
Uma chamada de função ou funciona, ou levanta. Uma chamada de rede tem um terceiro estado: não se sabe. Ela pode ter chegado e a resposta se perdido; pode estar a caminho; pode ter sido processada duas vezes.
Quase todo bug de microserviço vem de tratar o terceiro estado como um dos dois primeiros:
| O que se faz | O que acontece |
|---|---|
| tentar de novo, sem cuidado | o pedido é processado duas vezes |
| esperar sem prazo | uma thread presa por serviço, para sempre |
| insistir num serviço caído | ele nunca se recupera, porque nunca para de receber |
| propagar só o dado | o rastro se perde na primeira fronteira |
Arcane.Malha trata os quatro.
Uma chamada#
adopt Arcane.Malha as Malha
cliente := Malha.cliente("https://estoque.interno", {
"prazo": 3.0,
"tentativas": 3,
"disjuntor": {"falhas": 5, "espera": 30}
})
r := cliente.get("/estoque/CAF-500")
given r["ok"]:
out r["body"]["quantidade"]
otherwise:
out $"nao deu: {r["erro"]} (status {r["status"]})"A resposta é um vault com ok, status, body, headers, tentativas, ms e — quando algo deu errado — erro.
`status: 0` é o terceiro estado#
status 500 → chegou, o servidor quebrou
status 404 → chegou, o pedido está errado
status 0 → NÃO CHEGOU (ou a resposta se perdeu)A diferença importa: um 500 foi processado, e um 0 talvez não. Para uma operação que cobra dinheiro, o 0 é o caso que exige a chave de idempotência.
Retry: só o que é seguro#
| Situação | Repete? |
|---|---|
GET, HEAD, OPTIONS, PUT, DELETE | sim — não mudam estado, ou são idempotentes |
POST ou PATCH sem chave | não — um pagamento repetido cobra duas vezes |
POST com Idempotency-Key | sim — é o servidor que garante o efeito único |
| 5xx, 429, 408, 425 | sim — o problema é do outro lado |
| outro 4xx | não — o pedido está errado; repetir dá o mesmo erro com mais latência |
status: 0 | sim — não chegou, vale tentar |
// repetido sem risco
cliente.get("/estoque/CAF-500")
// NÃO é repetido
cliente.post("/pagamentos", {"valor": 100.0})
// repetido, porque o servidor garante
cliente.post("/pagamentos", {"valor": 100.0}, chave := uuid())Recuo exponencial, com tremor#
tentativa 1 → espera até 0,2 s
tentativa 2 → espera até 0,4 s
tentativa 3 → espera até 0,8 s
tentativa 4 → espera até 1,6 s (teto: 10 s)O tremor não é refinamento. Sem ele, cem clientes que falharam junto tentam de novo junto — e a rajada derruba o serviço que estava se recuperando. É a diferença entre uma recuperação e um laço de queda.
Disjuntor#
Sem ele, um serviço que cai leva os que dependem dele: cada pedido espera o prazo inteiro antes de falhar, as threads acabam, e o que estava de pé cai também. E o serviço caído nunca se recupera, porque nunca para de receber.
fechado ────falhas demais────▶ aberto
▲ │
│ espera passou
sucesso │
│ ▼
└────────────────────────── entreaberto
(deixa UM passar)cliente := Malha.cliente(base, {
"disjuntor": {"falhas": 5, "espera": 30}
})
// depois de 5 falhas, a chamada NÃO SAI:
r := cliente.get("/x")
// {"ok": no, "status": 0, "erro": "disjuntor aberto para 'estoque'",
// "tentativas": 0, "espera_restante": 27.4}O entreaberto é o que evita a avalanche na volta: com cem threads esperando, abrir tudo de uma vez derruba o serviço no instante em que ele volta. Uma passa; se der certo, fecha; se falhar, abre de novo e o relógio reinicia — sem reiniciar, uma fila de threads sondaria em rajada.
Malha.disjuntor(falhas := 3, espera := 10) // um, avulso
cliente.disjuntor.estado // "fechado" | "aberto" | "entreaberto"
cliente.disjuntor.resumo()Descoberta#
Não cravar http://localhost:8080 no código, sem precisar de um registro distribuído:
Malha.registrar("estoque", "http://estoque:8080", {"prazo": 2.0})
Malha.registrar("pagamentos", "http://pagamentos:8080")
cliente := Malha.de("estoque") // o MESMO cliente, sempreSem registro explícito, ele procura a variável de ambiente — ESTOQUE_URL ou ESTOQUE_HOST —, que é a convenção que o Docker Compose e o Kubernetes já produzem:
services:
pedidos:
environment:
ESTOQUE_URL: http://estoque:8080
Quando o serviço não é conhecido, a mensagem diz o que fazer:
erro: nao sei onde esta o servico 'estoque'.
nota: procurei no registro e na variavel ESTOQUE_URL
dica: Malha.registrar("estoque", "http://estoque:8080"),
ou defina ESTOQUE_URLO rastro do pedido#
Sem um identificador que viaja com o pedido, investigar um incidente em cinco serviços é cruzar horário de log — o que é impossível quando dois pedidos acontecem no mesmo segundo.
adopt Kiln
adopt Arcane.Malha as Malha
server pedidos on 8080:
// Na borda: continua o rastro que chegou, ou começa um.
middleware lambda req: Malha.propagar(req, "pedidos")
route POST "/pedidos":
// A chamada de saída leva o rastro sozinha.
r := Malha.de("estoque").get($"/estoque/{req["body"]["sku"]}")
given not r["ok"]:
respond 503 json {"erro": "estoque indisponível"}
respond 201 json criar(req["body"])A propagação é automática na saída: um cliente que obrigasse a passar o id em cada chamada o perderia na primeira vez que alguém esquecesse — e esquecer é o caso normal.
| Chamada | Faz |
|---|---|
Malha.propagar(req, origem) | continua o rastro que chegou, ou começa um |
Malha.rastro() | o id de agora — para pôr no log |
Malha.contexto() | o vault inteiro: rastro, origem, extra |
Malha.comecar_contexto(id, origem, extra) | à mão, fora de uma rota |
Malha.cabecalhos_de_contexto() | o que vai nas chamadas de saída |
// o 'extra' também viaja — o inquilino, a versão do cliente
Malha.comecar_contexto(void, "web", {"tenant": "acme"})
// vira X-Ctx-Tenant: acme em toda chamada de saídaO contexto vive numa threading.local: duas requisições ao mesmo tempo não misturam rastro. E o rastro também entra em req["state"]["rastro"], para o log do Kiln o alcançar sem passar pelo contexto.
Observar#
cliente.resumo()
// {"servico": "estoque", "chamadas": 1204, "erros": 7,
// "retentativas": 12, "recusadas": 0, "media_ms": 34.2,
// "disjuntor": {"estado": "fechado", "falhas": 0, …}}
Malha.saude() // o resumo de TODOS os clientes ativosMalha.saude() é o que se põe numa rota de diagnóstico: ela responde quais dependências deste serviço estão de pé, que é a primeira pergunta num incidente.
route GET "/saude/dependencias":
respond json {"servicos": Malha.saude()}Um sistema em três serviços#
O caminho completo, com tudo junto:
// ── pedidos/src/main.df ──
adopt Kiln
adopt Arcane.Malha as Malha
adopt Arcane.Database as Banco
db := Banco.connect("pedidos.db")
// As opções valem para todo cliente criado daqui para frente.
Malha.padrao({"prazo": 3.0, "tentativas": 3,
"disjuntor": {"falhas": 5, "espera": 30}})
server pedidos on 8080:
middleware lambda req: Malha.propagar(req, "pedidos")
middleware Kiln.logger()
route POST "/pedidos":
item := req["body"]
// 1. o estoque tem?
e := Malha.de("estoque").get($"/estoque/{item["sku"]}")
given not e["ok"]:
respond 503 json {
"erro": "não deu para confirmar o estoque",
"rastro": Malha.rastro()
}
given e["body"]["quantidade"] smaller item["quantidade"]:
respond 409 json {"erro": "estoque insuficiente"}
// 2. grava o pedido e reserva, numa transação
action corpo():
id := Banco.insert(db, "pedidos", {
"sku": item["sku"],
"quantidade": item["quantidade"],
"status": "reservando"
})
// A chave de idempotência é o id do pedido: se a resposta
// se perder e o cliente repetir, o estoque não baixa duas
// vezes.
r := Malha.de("estoque").post("/reservas", {
"sku": item["sku"], "quantidade": item["quantidade"]
}, chave := $"pedido-{id}")
given not r["ok"]:
trigger $"reserva recusada: {r["erro"] ?? r["status"]}"
Banco.update(db, "pedidos", {"status": "reservado"}, {"id": id})
yield id
monitor:
id := Banco.transacao(db, corpo)
respond 201 json {"id": id, "rastro": Malha.rastro()}
handle Error as erro:
respond 502 json {"erro": erro.message,
"rastro": Malha.rastro()}
route GET "/saude":
respond json {"estado": "ok"}
route GET "/saude/dependencias":
respond json {"servicos": Malha.saude()}
ignite pedidos on 8080 at "0.0.0.0"Quatro coisas acontecem aí que não aconteceriam com um cliente HTTP cru:
| O quê | |
|---|---|
| 1 | o estoque fora do ar devolve 503, e não uma exceção que vira 500 — a diferença muda o que o balanceador faz |
| 2 | a reserva leva chave := "pedido-N": se a resposta se perder e o cliente repetir, o estoque não baixa duas vezes |
| 3 | o pedido só fica reservado se a reserva confirmou — a transação desfaz o resto |
| 4 | o rastro volta na resposta de erro: quem recebeu o 502 pode dizer qual foi, e o log dos três serviços é pesquisável por ele |
Saga: escritas que precisam acontecer juntas#
Não existe transação que atravesse a rede. BEGIN no serviço de estoque não alcança o de cobrança, e nem deveria: um BEGIN distribuído pediria que cada serviço segurasse uma transação aberta esperando os outros, o que transforma a queda de um na queda de todos.
A resposta correta é compensar — cada passo declara como se desfaz, e uma falha no meio desfaz em ordem inversa o que já aconteceu.
reservar ──▶ cobrar ──▶ despachar
│
falhou
│
◀─── estornar ◀─┘
liberar ◀───adopt Arcane.Malha as Malha
action reservar(estado, chave):
r := Malha.de("estoque").post("/reservas", {
"sku": estado["sku"], "quantidade": estado["quantidade"]
}, chave := chave)
given not r["ok"]:
trigger $"reserva recusada: {r["erro"] ?? r["status"]}"
yield {"reserva": r["body"]["id"]}
action liberar(estado, chave):
Malha.de("estoque").post($"/reservas/{estado["reserva"]}/liberar",
void, chave := chave)
action cobrar(estado, chave):
r := Malha.de("cobranca").post("/cobrancas", {
"valor": estado["valor"]
}, chave := chave)
given not r["ok"]:
trigger $"cobranca recusada: {r["status"]}"
yield {"cobranca": r["body"]["id"]}
action estornar(estado, chave):
Malha.de("cobranca").post($"/cobrancas/{estado["cobranca"]}/estorno",
void, chave := chave)
// O id da saga e o do pedido: e o que torna a chave de cada passo
// estavel entre execucoes.
s := Malha.saga("checkout", $"pedido-{id}")
s.passo("reservar", reservar, liberar)
s.passo("cobrar", cobrar, estornar)
s.passo("despachar", despachar, cancelar_despacho)
// ANTES de executar: nenhum passo que escreve sem compensacao.
given s.conferir() is not []:
trigger $"passos sem compensacao: {s.conferir()}"
r := s.executar({"sku": "CAF-500", "quantidade": 2, "valor": 65.0})
given r["ok"]:
out $"pedido fechado em {r["ms"]}ms"
otherwise:
out $"parou em {r["falhou_em"]}: {r["erro"]}"
out $"desfeitos: {r["desfeitos"]}"
given r["orfas"] is not []:
// Isto precisa chegar a um humano.
out $"COMPENSACAO FALHOU: {r["orfas"]}"Três coisas que a diferenciam de um `monitor` com `ensure`#
| O quê | Por quê | |
|---|---|---|
| 1 | a compensação roda em ordem inversa | estornar a cobrança antes de liberar o estoque deixa uma janela em que o cliente não tem dinheiro nem produto |
| 2 | uma compensação que falha não é engolida | um estorno que não passa deixa o sistema inconsistente, e isso precisa chegar a um humano — ok continua no mesmo que o resto tenha desfeito |
| 3 | cada passo tem chave de idempotência estável | derivada do id da saga mais o nome do passo: reprocessar uma fila não cobra duas vezes |
O passo que falhou não é compensado. Ele não concluiu, e desfazer o que não aconteceu é o outro lado do mesmo bug — um estorno de cobrança que nunca existiu devolve dinheiro que nunca foi cobrado.
`conferir()` roda antes de executar#
Um passo que escreve e não declara compensação não falha em teste feliz. Ele aparece no dia em que o passo seguinte falha — e aí já escreveu.
s := Malha.saga("risco")
s.passo("consultar", consultar_score, escreve := no)
s.passo("marcar", marcar_cliente)
assert s.conferir() is ["marcar"]escreve := no é a declaração de que o passo não precisa de compensação: uma leitura, um log, uma consulta de score.
O diário, e por que a cada passo#
// O terceiro argumento e onde escrever cada linha.
s := Malha.saga("checkout", $"pedido-{id}",
lambda linha: Banco.insert(db, "saga_log", linha))Gravado a cada passo, e não no fim: uma queda do processo no meio da saga perderia o que já foi feito, e ninguém saberia o que compensar. Cada linha leva o rastro do contexto, o que liga o diário da saga ao log dos serviços que ela chamou.
Um diário que não grava não derruba a saga — ela está no meio de escritas reais em serviços reais, e falhar por causa do log seria trocar um problema pequeno por um grande.
O que este módulo NÃO é#
Ele não é um service mesh. Não há sidecar, não há plano de controle, não há mTLS nem roteamento por peso. Isso é trabalho de infraestrutura — Istio, Linkerd, ou o balanceador do seu provedor — e reimplementá-lo em Python daria um subconjunto pior amarrado à linguagem.
O que ele é: o que uma chamada entre serviços precisa para não mentir, escrito sobre o urllib da biblioteca padrão, sem dependência.
Onde continuar#
OpenAPI
do código para o contratoOpenAPI, Insomnia, Postman e curl, gerados das rotas.
WebSocket e SSE
Quando o servidor precisa falar primeiro.
Observabilidade
Métrica, traço e linhagem.
DevOps
Os artefatos que levam os serviços ao ar.
Os exercícios
227 e 228Dois serviços de verdade sobre sockets, e uma saga que compensa.