Lab 25 — Orquestrar com Step Functions, ou no código?
O problema, e a empresa que o tem
Depois do L22, a fila da Cadência absorve o pico e um Lambda consumidor processa cada pedido sozinho, com idempotência garantida por uma tabela do DynamoDB. Funcionou — até o produto pedir para o checkout crescer: validar o pedido contra regras simples de fraude, reservar estoque, cobrar o cartão e notificar o cliente por e-mail. Quatro chamadas, cada uma podendo falhar de um jeito diferente.
A resposta natural foi acrescentar cada chamada nova ao mesmo método que já existia, dentro do mesmo bloco try/catch. Um ano depois, o arquivo ProcessarPedido.cs tem 400 linhas. Os pedidos passam — mas ninguém no time responde, sem abrir o arquivo e ler de cima a baixo, "o que acontece se a cobrança falhar na terceira tentativa?"
O incidente que forçou a pergunta: a cobrança falhou, o código tentou desfazer a reserva de estoque, e a chamada de desfazer TAMBÉM lançou exceção — capturada por um catch genérico dois níveis acima, que só logava e seguia. O estoque ficou reservado para um pedido nunca cobrado, e ninguém soube por três dias, até o suporte notar um item "esgotado" que não deveria estar.
O que este laboratório NÃO é
Não é saga completa com compensação em todo passo — aqui só a cobrança tem estado de compensação nomeado, e isso é o suficiente para o problema declarado. Compensação em todos os passos, com falha injetada em cada um, é o L35. Também não é sobre volume: a Cadência processa menos de um pedido por segundo, e a decisão deste módulo não muda com a taxa — muda com a necessidade de auditoria e com o tipo de erro.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Explicar por que misturar "em que passo estou" com "quantas vezes já tentei" no mesmo texto torna um try/catch difícil de auditar.
- Escrever Retry declarativo com IntervalSeconds, BackoffRate, MaxAttempts e MaxDelaySeconds, e prever o instante de cada tentativa.
- Distinguir erro retryable de erro terminal e expressar isso como retriers diferentes no mesmo estado, na ordem certa.
- Escrever um Catch que roteia para um estado de compensação nomeado, preservando o erro original com ResultPath.
- Justificar Standard sobre Express para um fluxo de cobrança, a partir de semântica de execução e auditoria — não de preço.
- Implementar o mesmo fluxo de 4 passos como Lambda único com try/catch e como máquina de estados, lado a lado.
- Injetar a mesma falha de cobrança nas duas implementações e comparar o que cada uma revela no diagnóstico.
- Ler o histórico de uma execução no console e apontar exatamente em qual estado e em qual tentativa ela mudou de rumo.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Máquina de estados em ASL | DVA-C02, SAP-C02 | texto real do JSON, não pseudocódigo | os campos de um Task state: Resource, Retry, Catch, Next/End |
| Retry com backoff exponencial | DVA-C02 | IntervalSeconds × BackoffRate, com teto opcional | que sem MaxDelaySeconds o intervalo cresce sem limite |
| Standard vs Express | DVA-C02, SAP-C02 | critério de escolha do módulo | exactly-once + auditoria decide, não o preço por unidade |
| Execução exactly-once vs at-least-once | SAP-C02 | por que a cobrança exige Standard | ação não idempotente não pode correr risco de rodar duas vezes |
| Ordem de avaliação de retriers | DVA-C02 | erro terminal ANTES do genérico na lista de Retry | o primeiro ErrorEquals que casa decide — não há fallback para o próximo |
| Catch e compensação (introdução à saga) | SAP-C02 | Catch de CobrarCartao aponta para LiberarEstoque | diferença entre erro tratado no próprio passo e erro que exige desfazer um passo anterior |
| Nome de execução como idempotência | DVA-C02 | StartExecution com o ID do pedido | Standard recusa segunda execução com o mesmo nome enquanto a primeira roda |
| Observabilidade nativa (histórico de execução) | SOA-C02 | evento por transição de estado, incluindo cada Retry | histórico completo no console é recurso de Standard, não de Express |
Onde isto costuma ser cobrado errado
A pergunta clássica dá dois retriers no mesmo Task state — um específico com `MaxAttempts: 0`, outro genérico depois — e pergunta o que acontece quando o erro específico ocorre. A resposta não é "tenta o genérico depois": Step Functions usa o PRIMEIRO retrier cujo `ErrorEquals` casa, aplica só a política dele, e não continua escaneando os seguintes para o mesmo erro.
Requisitos, e como cada um muda o desenho
Requisito não funcional que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Responder "o que acontece se o passo X falhar" sem ler o código | obrigatório | separa orquestração (ASL, visível) do código de cada passo (pequeno, testável) |
| Cobrança não pode duplicar | obrigatório | Standard (exactly-once), não Express — e retry só no NÍVEL do estado, não na chamada HTTP |
| Distinguir falha transitória de falha definitiva na cobrança | obrigatório | dois erros tipados (GatewayIndisponivelError, CartaoRecusadoError) com retriers diferentes |
| Auditoria de qualquer pedido por pelo menos 30 dias | financeiro | Standard Workflow: histórico de execução nativo, retido por até 90 dias |
| Volume atual | menos de 1 pedido/s, picos de 5/s | não justifica Express; a característica que decide é auditoria, não taxa |
| Se a cobrança falhar de vez, o estoque reservado tem de voltar | obrigatório | estado de Catch dedicado (LiberarEstoque), não um `finally` genérico |
| Time de duas pessoas, sem plantão | restrição de equipe | alarme em `ExecutionsFailed`, não vigília manual do console |
Arquitetura no código: o Lambda que sabe fazer tudo
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: publica de verdade, com uma função e algumas chamadas de SDK. O laboratório começa aqui porque a topologia simples é exatamente o que esconde o problema real.
- → mensagem do pedido (SQS, do L22)
- → reserva de estoque, no mesmo bloco try
- → cobrança, com laço de retry escrito à mão
- → confirmação, só no caminho feliz
- → uma linha de log por tentativa
- Integração de apps
- Compute
- Banco de dados
- Fora da AWS
- Gestão e governança
Este desenho publica hoje, e a topologia é enganosamente simples. O que ele não mostra — porque não pode — é a ORDEM entre as chamadas e o que acontece quando a cobrança falha; essa resposta mora inteira dentro da caixa "proc", em texto. Percorra os passos e repare que cada seta só existe porque a anterior não lançou exceção.
- A fila entrega, e a decisão inteira mora numa função. A partir daqui, tudo o que acontece com o pedido — validar, reservar, cobrar, notificar e, se precisar, desfazer — é decidido por texto sequencial dentro de um único handler. Não há um segundo lugar para consultar.
- Reservar estoque é só mais um bloco try no mesmo arquivo. A chamada condicional ao DynamoDB está a poucas linhas da validação anterior. Funciona bem sozinha — o problema aparece quando o próximo passo, a cobrança, precisa saber se deve ou não desfazer esta.
- Cobrar é onde o arquivo cresceu: cada tentativa é um `for` com `Thread.Sleep`. É o ponto de falha injetado neste laboratório. O laço de retry manual mistura duas perguntas no mesmo texto: "isto é um erro transitório?" e "quantas vezes já tentei?". Separar as duas é exatamente o que o desenho seguinte faz.
- Se tudo deu certo, notifica — mas "tudo certo" é um booleano em quatro níveis de aninhamento. Chegar até aqui depende de nenhuma das chamadas anteriores ter lançado exceção não capturada por um catch mais estreito. Ler essa garantia exige acompanhar o fluxo de controle inteiro, não só olhar esta seta.
- Se a cobrança falhar de vez, o mesmo arquivo tenta desfazer a reserva — 200 linhas antes. A chamada de compensação existe, mas está fisicamente distante da chamada que a motivou. É o tipo de código que passa em revisão e falha em produção: o catch que a captura foi escrito para "logar e seguir", não para tentar de novo o desfazer.
- A única prova de qual passo falhou é abrir o log e ler a exceção. Não existe histórico estruturado por passo — existe uma sequência de linhas de texto, na ordem em que foram escritas, e cabe a quem investiga reconstruir a história. É a pergunta que este laboratório resolve de outro jeito.
O jeito mais barato de cobrar duas vezes
O laço de retry manual dentro de `CobrarCartao` repete a chamada HTTP ao gateway sem nenhuma chave de idempotência própria. Se o gateway processou a cobrança e só a RESPOSTA se perdeu por timeout, a tentativa seguinte cobra de novo — dinheiro saindo do cartão do cliente duas vezes por um problema de rede, não de lógica de negócio. Retry, em qualquer das duas arquiteturas deste módulo, só é seguro se a chamada por trás dele for idempotente; a orquestração declarativa não resolve isso sozinha.
Arquitetura com Step Functions: o mesmo problema, orquestração declarativa
Cada peça nova abaixo rastreia a um requisito da seção anterior. O código de cada passo fica menor porque parou de carregar a decisão de "o que vem depois" — essa decisão passou para o ASL, ao lado.
- → mensagem do pedido (SQS, do L22)
- → StartExecution, nome = ID do pedido
- → primeiro estado da máquina
- → pedido válido, próximo estado
- → UpdateItem condicional (decrementa se saldo ≥ 1)
- → estoque reservado, próximo estado
- → cobrança HTTPS, com Retry declarado no ASL
- → cobrança aprovada, próximo estado
- → publica confirmação de pedido
- → Catch: recusa definitiva ou tentativas esgotadas
- → UpdateItem condicional (devolve a unidade reservada)
- → PutItem com o motivo da falha
- → cada transição de estado vira evento no histórico
- Integração de apps
- Compute
- Banco de dados
- Fora da AWS
- Gestão e governança
A topologia muda de seis caixas para dez, e é exatamente aí que a pergunta "o que acontece se a cobrança falhar" ganha resposta: um `Catch` com nome e destino visíveis no desenho, não uma linha perdida num catch genérico. Percorra os passos: cada Lambda faz uma coisa, e a ordem e o retry vivem na máquina.
- Uma mensagem, uma execução nomeada. O iniciador chama `StartExecution` usando o ID do pedido como nome da execução. Um Standard Workflow rejeita iniciar uma segunda execução com o mesmo nome enquanto a primeira roda — idempotência de execução sem escrever nada para isso.
- O primeiro estado só valida, e é só isso. ValidarPedido é uma função pequena, sem retry escrito nela e sem saber o que vem depois. Testá-la isoladamente não exige simular fila, gateway nem DynamoDB.
- Reservar estoque tem um erro que não vale a pena repetir. `EstoqueInsuficienteError` está configurado com `MaxAttempts: 0` — o retrier casa com o erro e o esgota na hora, sem nenhuma tentativa nova. Repetir uma reserva que já sabe que vai falhar de novo só atrasa a resposta ao cliente.
- Cobrar é o único passo com Retry escrito, e ele é declarativo. A política de tentativa — quantas vezes, com que espera, com que teto — está no JSON da máquina, ao lado do estado, não dentro do código do Lambda. Quem pergunta "o que acontece se o passo 4 falhar" lê este trecho e tem a resposta.
- Falha permanente não passa por notificar — o Catch decide isso, visível. Quando o gateway recusa definitivamente, ou quando o Retry se esgota, o `Catch` de CobrarCartao aponta direto para LiberarEstoque. NotificarCliente nunca é alcançado nesse caminho — a exclusão é uma seta no desenho, não um `if` escondido.
- A compensação desfaz e registra, e as duas coisas aparecem no histórico. LiberarEstoque devolve a unidade reservada e grava o motivo na tabela de falhas. As duas ações são passos nomeados da mesma máquina — não um bloco de "limpeza" torcendo para não lançar uma segunda exceção sem ninguém ver.
- O histórico da execução é a resposta a "o que houve com o pedido 482". Cada transição de estado — inclusive cada tentativa de Retry — vira um evento com carimbo de tempo, consultável por até 90 dias. É a diferença entre grepar log de texto e abrir uma linha do tempo por pedido.
A diferença estrutural não é "mais uma caixa": é que a pergunta "o que acontece se a cobrança falhar" deixou de exigir ler um arquivo e passou a ter um lugar fixo para morar — o campo `Catch` do estado `CobrarCartao`, com o nome do próximo estado escrito ao lado.
O efeito colateral que ninguém pediu, e todo mundo usa
Como cada estado é uma função pequena e sem laço de retry embutido, testar `CobrarCartao` isoladamente — com um mock do gateway — deixou de exigir simular fila, DynamoDB e SNS ao mesmo tempo. O teste de unidade ficou pequeno porque o estado ficou pequeno; nenhuma das duas coisas foi objetivo direto do laboratório.
O caminho de um pedido, ponta a ponta
Os seis estados não são um detalhe de implementação: são a resposta a "em que passo isto falhou", disponível sem abrir código. Isso é observável com get-execution-history durante ou depois da execução, e a seção de provas faz exatamente isso.
O teto que evita esperar um dia inteiro por uma resposta
Sem `MaxDelaySeconds`, `IntervalSeconds: 2` com `BackoffRate: 3` chegaria a esperas de minutos depois de poucas tentativas — 2, 6, 18, 54 segundos, e crescendo. Com o teto em 10, a partir da terceira tentativa a espera vira sempre 10 segundos: o `MaxAttempts` continua contando tentativas, mas o TEMPO entre elas para de crescer.
// O input que o iniciador envia ao StartExecution. E tambem, com o campo
// "forcarFalhaGateway", o gancho que a secao de provas usa para injetar a
// mesma falha nas duas arquiteturas deste modulo.
{
"pedidoId": "p-48213",
"clienteId": "c-9981",
"itens": [{ "sku": "sku-123", "quantidade": 1 }],
"cartaoToken": "tok_xxx", // token do gateway, NUNCA o numero do cartao
"valor": 249.90
}O que a entrada de LiberarEstoque recebe quando o Catch de CobrarCartao dispara, com ResultPath: "$.erro" preservando o restante do pedido.
{
"pedidoId": "p-48213",
"itens": [{ "sku": "sku-123", "quantidade": 1 }],
"erro": {
"Error": "CartaoRecusadoError",
"Cause": "gateway respondeu recusa: saldo insuficiente"
}
}As decisões, e o que se perde em cada uma
📋 Processar pedidos numa fila (do L22), com um fluxo de 4 chamadas — validar, reservar estoque, cobrar cartão, notificar — em que a cobrança é a única ação que não pode ser repetida sem uma chave de idempotência própria, e o time de duas pessoas precisa responder "o que aconteceu com o pedido X" sem ler código.
O requisito central não é performance nem custo — é auditabilidade e clareza de retry por tipo de erro. Standard entrega histórico de execução nativo por até 90 dias e execução exactly-once, que é o que uma ação de cobrança pede. O código de cada passo fica pequeno e testável sozinho, porque parou de carregar a decisão de "o que vem depois" — essa decisão é do JSON da máquina, não do C#.
Alt: Manter tudo no código, com uma biblioteca de retry (Polly) — Resolve o backoff exponencial — é o assunto do L36 — mas não resolve a pergunta original: onde está escrito o que acontece depois de cada passo. A ordem e as ramificações continuam em texto sequencial, só que com menos `Thread.Sleep`.
Alt: Express Workflow em vez de Standard — Mais barato por unidade e sem limite prático de taxa de execução, mas troca exactly-once por at-least-once. A cobrança do cartão não é idempotente por padrão — rodar CobrarCartao duas vezes cobraria duas vezes. Fica para quando o volume justificar E cada passo tiver sua própria proteção contra duplicata.
Alt: SDK de execução durável (escrever o fluxo em código, com pontos de durabilidade automáticos) — É uma categoria de ferramenta em evolução no ecossistema de orquestração; não está confirmada como parte do escopo cobrado pelas certificações DVA-C02/SAP-C02 em 2026, e não foi verificada em profundidade para este módulo — se você a achar, confira a documentação oficial antes de adotar em produção.
Alt: Saga completa com compensação em todos os passos (L35) — É o próximo nível de rigor: aqui só CobrarCartao tem compensação nomeada. Se ReservarEstoque também precisar desfazer algo além de si mesma, ou se houver mais de dois passos não idempotentes, o desenho deste laboratório não basta.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Tipo de workflow | Standard | Express; Express síncrono | exactly-once e histórico de 90 dias — o que a cobrança e a auditoria exigem | preço por transição em vez de por duração; em alto volume isso pesa mais |
| Onde mora o retry | declarado no ASL, por estado | biblioteca de retry no código (Polly, L36); laço manual | a política fica visível ao lado do estado, sem reler código para saber o valor | menos controle fino sobre lógica de retry condicional complexa dentro de um único passo |
| Erro terminal vs transitório | dois retriers tipados no mesmo estado | um retrier genérico só (`States.TaskFailed`) | recusa definitiva não espera 4 tentativas para falhar — resposta mais rápida ao cliente | exige que o código do Lambda lance exceções com nomes distintos, não uma genérica |
| Compensação | estado nomeado (LiberarEstoque) via Catch | try/finally dentro do próprio código de cobrança | a compensação aparece no histórico como um passo, não como uma linha de log | mais um recurso (Lambda) para gerenciar e mais um papel IAM |
| Nome da execução | ID do pedido | nome aleatório (UUID gerado na hora) | idempotência de execução sem tabela nem lógica extra | nenhuma; é estritamente melhor aqui, e é o padrão recomendado |
| Granularidade dos Lambdas | um por passo (4 funções pequenas) | um Lambda "cola" que só invoca outras funções em sequência (orquestração fingida) | cada função é testável sozinha e o papel IAM de cada uma é mínimo | mais artefatos de deploy; a superfície de configuração cresce |
A dívida que este laboratório NÃO paga
Só `CobrarCartao` tem compensação nomeada. Se `ReservarEstoque` precisar desfazer algo além de si mesma no futuro — por exemplo, um cupom de desconto já aplicado — este desenho não cobre. Saga com compensação em todos os passos não idempotentes é o L35, e ele parte exatamente daqui.
Construir: o monólito, para comparar
Este código não vai para produção — ele é o ponto de partida que o resto do módulo substitui. Vale lê-lo devagar: o defeito não é um bug, é a forma. O arquivo real da Cadência tem cerca de 400 linhas; o trecho abaixo é representativo, com os pontos omitidos marcados.
// ProcessarPedido.cs — excerto representativo do arquivo real (~400 linhas).
// Comentários indicam onde o arquivo continua com mais casos de borda e logging.
public class ProcessarPedidoHandler
{
public async Task FunctionHandler(SQSEvent evento, ILambdaContext ctx)
{
foreach (var msg in evento.Records)
{
var pedido = JsonSerializer.Deserialize<Pedido>(msg.Body)!;
// ── Passo 1: idempotência + validação (~40 linhas omitidas) ──────
if (await _idempotencia.JaProcessadoAsync(pedido.Id)) continue;
if (!ValidarRegrasDeFraude(pedido)) { RegistrarRejeicao(pedido); continue; }
// ── Passo 2: reservar estoque, dentro do MESMO try ────────────────
bool estoqueReservado = false;
try
{
await _dynamo.UpdateItemAsync(ReservarEstoqueRequest(pedido));
estoqueReservado = true;
// ── Passo 3: cobrar — o laço de retry manual, escrito à mão ──
var tentativas = 0;
var atraso = TimeSpan.FromSeconds(2);
CobrancaResultado? resultado = null;
while (tentativas < 4)
{
try
{
resultado = await _gateway.CobrarAsync(pedido.CartaoToken, pedido.Valor);
break; // saiu do laço com sucesso
}
catch (GatewayIndisponivelException) when (tentativas < 3)
{
// Confunde-se com erro definitivo se alguém remover o "when" por engano —
// nada aqui distingue este catch do de baixo, exceto a condição da guarda.
tentativas++;
await Task.Delay(atraso);
atraso *= 3; // backoff, sem teto: pode crescer sem limite
continue;
}
catch (CartaoRecusadoException)
{
// Erro definitivo: não deveria entrar no laço, mas está no MESMO catch
// block que trata o erro transitório — só a exceção lançada difere.
throw;
}
}
if (resultado is null) throw new CobrancaFalhouException(pedido.Id);
// ── Passo 4: notificar — só chega aqui se nada acima lançou ──
await _sns.PublishAsync(NotificacaoDe(pedido, resultado));
await _idempotencia.MarcarProcessadoAsync(pedido.Id);
}
catch (Exception ex)
{
// ── Compensação: 200 linhas depois da reserva que a motivou ──
if (estoqueReservado)
{
try { await _dynamo.UpdateItemAsync(LiberarEstoqueRequest(pedido)); }
catch (Exception exLiberar)
{
// O INCIDENTE REAL deste laboratório: este catch só loga e segue.
// Se a liberação falhar, o estoque fica reservado para um pedido
// que nunca foi cobrado, e ninguém percebe até o suporte notar.
_log.LogError(exLiberar, "falha ao liberar estoque do pedido {Id}", pedido.Id);
}
}
_log.LogError(ex, "falha ao processar pedido {Id}", pedido.Id);
// ... mais ~250 linhas: retentativa de notificação de erro, métricas
// customizadas por tipo de exceção, e três outros fluxos de pedido
// (troca, cancelamento, reembolso parcial) que crescem no mesmo arquivo.
}
}
}
}
O catch que engoliu o incidente
Repare no `catch (Exception exLiberar)` dentro do bloco de compensação: ele loga e segue. Não há Retry ali, não há alarme dedicado, e a próxima linha do arquivo é o `_log.LogError` de fora — que registra a falha ORIGINAL, não a falha de liberar o estoque. Foi assim que o incidente real ficou invisível por três dias: o log continha a informação, mas ninguém sabia procurar por ela.
Construir: a máquina de estados, em ASL real
Este é o texto que substitui o fluxo de controle do monólito. Cada estado é um `Task` com `Resource`, e a diferença entre falha transitória e falha definitiva vira dois objetos dentro do mesmo array `Retry` — não dois `catch` no mesmo bloco de código.
{
"Comment": "Cadencia - processamento de pedido, pos-fila do L22",
"StartAt": "ValidarPedido",
"States": {
"ValidarPedido": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:cadencia-validar-pedido",
"Retry": [
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2, "MaxAttempts": 2, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.erro", "Next": "RegistrarFalha" }
],
"Next": "ReservarEstoque"
},
"ReservarEstoque": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:cadencia-reservar-estoque",
"Retry": [
{ "ErrorEquals": ["EstoqueInsuficienteError"], "MaxAttempts": 0 },
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 1, "MaxAttempts": 2, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.erro", "Next": "RegistrarFalha" }
],
"Next": "CobrarCartao"
},
"CobrarCartao": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:cadencia-cobrar-cartao",
"Retry": [
{
"ErrorEquals": ["GatewayIndisponivelError"],
"IntervalSeconds": 2,
"MaxAttempts": 4,
"BackoffRate": 3.0,
"MaxDelaySeconds": 10,
"JitterStrategy": "FULL"
},
{ "ErrorEquals": ["CartaoRecusadoError"], "MaxAttempts": 0 }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.erro", "Next": "LiberarEstoque" }
],
"Next": "NotificarCliente"
},
"NotificarCliente": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:cadencia-notificar-cliente",
"Retry": [
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 1, "MaxAttempts": 2, "BackoffRate": 2.0 }
],
"End": true
},
"LiberarEstoque": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:cadencia-liberar-estoque",
"Retry": [
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Next": "RegistrarFalha"
},
"RegistrarFalha": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Parameters": {
"TableName": "cadencia-pedidos-falhos",
"Item": {
"pedidoId": { "S.$": "$.pedidoId" },
"motivo": { "S.$": "$.erro.Cause" }
}
},
"End": true
}
}
}
Por que o retrier de erro terminal vem ANTES do genérico
Step Functions escaneia os retriers do array `Retry` na ordem em que aparecem e usa o PRIMEIRO cujo `ErrorEquals` casa com o erro. Se `CartaoRecusadoError` viesse depois de um retrier genérico como `States.ALL`, o genérico casaria primeiro e tentaria de novo uma recusa definitiva — o oposto do que se quer. A ordem no array é a decisão, não um detalhe estético.
Construir: os passos, pequenos e testáveis sozinhos
Cada Lambda faz uma coisa e lança um erro TIPADO quando falha. Quem decide se aquele erro merece uma nova tentativa não é esta função — é o `Retry` do estado correspondente, que casa pelo nome da exceção.
// CobrarCartao/Function.cs — um passo, uma responsabilidade, sem laço de retry
public class Function
{
public async Task<CobrancaResultado> FunctionHandler(EntradaCobranca entrada, ILambdaContext ctx)
{
try
{
return await _gateway.CobrarAsync(entrada.CartaoToken, entrada.Valor);
}
catch (HttpRequestException) when (EhTransitorio())
{
// Lança um erro TIPADO — é ele que o Retry do ASL casa pelo nome.
// Esta funcao NAO decide quantas vezes tentar: so decide QUE TIPO de erro é.
throw new GatewayIndisponivelError("gateway sem resposta em 5s");
}
catch (CartaoRecusadoException ex)
{
// Erro definitivo, nome diferente: no ASL, este casa com um retrier
// de MaxAttempts=0. Tentar de novo uma recusa nao muda o resultado.
throw new CartaoRecusadoError(ex.MotivoRecusa);
}
}
}
// ReservarEstoque/Function.cs — a mesma ideia, outro tipo de erro nao-retryable
public class FunctionEstoque
{
public async Task<ReservaResultado> FunctionHandler(EntradaReserva entrada, ILambdaContext ctx)
{
var resultado = await _dynamo.UpdateItemAsync(new UpdateItemRequest
{
TableName = "cadencia-pedidos",
Key = ChaveDoItem(entrada.Sku),
UpdateExpression = "SET saldo = saldo - :qtd",
ConditionExpression = "saldo >= :qtd", // falha se nao houver saldo suficiente
ExpressionAttributeValues = ValoresDaCondicao(entrada.Quantidade),
});
// A excecao do SDK para condicao falha vira, no handler, um erro nomeado.
// E ESTE NOME que o retrier "EstoqueInsuficienteError" do ASL reconhece.
return new ReservaResultado(resultado);
}
}
O teste que ficou possível
Testar `CobrarCartao` isoladamente virou simular duas respostas do gateway — sucesso e recusa — e verificar qual exceção sai. Não há laço, não há `Thread.Sleep`, não há estado de "quantas vezes já tentei" para inicializar no teste.
Construir: Terraform — a máquina, o papel mínimo e o alarme
O papel de execução da máquina é diferente do papel de execução de cada Lambda: ele só precisa invocar as funções do fluxo e escrever na tabela de falhas — nada além disso.
# stepfunctions.tf — a maquina, o papel minimo e o alarme
# O papel de execucao da MAQUINA (nao dos Lambdas). So invoca as quatro
# funcoes deste fluxo — nunca "lambda:InvokeFunction" em "*", que autorizaria
# a maquina a chamar qualquer funcao da conta, inclusive uma sem relacao com pedidos.
data "aws_iam_policy_document" "sfn_permissoes" {
statement {
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [
aws_lambda_function.validar.arn,
aws_lambda_function.reservar.arn,
aws_lambda_function.cobrar.arn,
aws_lambda_function.notificar.arn,
aws_lambda_function.liberar.arn,
]
}
statement {
effect = "Allow"
actions = ["dynamodb:PutItem"]
resources = [aws_dynamodb_table.pedidos_falhos.arn] # so a tabela de falhas
}
}
resource "aws_iam_role" "sfn_execucao" {
name = "${var.projeto}-sfn-pedidos"
assume_role_policy = data.aws_iam_policy_document.sfn_assume.json
}
resource "aws_iam_role_policy" "sfn_permissoes" {
role = aws_iam_role.sfn_execucao.id
policy = data.aws_iam_policy_document.sfn_permissoes.json
}
# Log group da maquina. include_execution_data grava o INPUT de cada estado —
# por isso o token do gateway nunca deve ir no input (ver secao de seguranca).
resource "aws_cloudwatch_log_group" "sfn_pedidos" {
name = "/aws/vendedlogs/states/${var.projeto}-pedidos"
retention_in_days = 30
}
resource "aws_sfn_state_machine" "pedidos" {
name = "${var.projeto}-pedidos"
role_arn = aws_iam_role.sfn_execucao.arn
type = "STANDARD" # exactly-once + historico de 90 dias; e a decisao central deste laboratorio
definition = file("${path.module}/maquina.asl.json")
logging_configuration {
log_destination = "${aws_cloudwatch_log_group.sfn_pedidos.arn}:*"
include_execution_data = true
level = "ALL"
}
}
# O iniciador encolheu: agora so chama StartExecution com o ID do pedido como
# nome — e e ESSE nome, nao codigo novo, que garante nao processar o mesmo
# pedido duas vezes ao mesmo tempo (Standard rejeita nome duplicado em execucao).
resource "aws_lambda_function" "iniciador" {
function_name = "${var.projeto}-iniciador-pedidos"
runtime = "dotnet8"
handler = "Iniciador::Cadencia.Pedidos.Iniciador.Function::FunctionHandler"
role = aws_iam_role.iniciador.arn
timeout = 10
memory_size = 256
filename = data.archive_file.iniciador.output_path
source_code_hash = data.archive_file.iniciador.output_base64sha256
environment {
variables = { MAQUINA_ARN = aws_sfn_state_machine.pedidos.arn }
}
}
resource "aws_lambda_event_source_mapping" "iniciador_pedidos" {
event_source_arn = aws_sqs_queue.pedidos.arn # a mesma fila do L22
function_name = aws_lambda_function.iniciador.arn
batch_size = 10
function_response_types = ["ReportBatchItemFailures"]
}
# Mede FALHAS DE EXECUCAO — nao confundir com falhas de UM ESTADO que o
# proprio Retry ja resolveu. Esta metrica so sobe quando a maquina inteira
# termina em FAILED, ou seja, quando nem o Retry nem o Catch resolveram.
resource "aws_cloudwatch_metric_alarm" "execucoes_falhas" {
alarm_name = "${var.projeto}-pedidos-execucoes-falhas"
namespace = "AWS/States"
metric_name = "ExecutionsFailed"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 5
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
dimensions = {
StateMachineArn = aws_sfn_state_machine.pedidos.arn
}
alarm_actions = [aws_sns_topic.alertas.arn]
}
O que muda no iniciador, e o que ele não sabe mais
O iniciador do L22 processava o pedido inteiro; agora ele só chama `StartExecution` e termina. Ele não sabe se a cobrança vai passar, não sabe quantas tentativas vão ocorrer, e não precisa saber — essa responsabilidade migrou para a máquina. É a mesma redução de escopo que aconteceu com `CobrarCartao` e `ReservarEstoque`.
Implantar, e provar que o retry fez o que o JSON diz
Cinco provas. Nenhuma aceita "a execução terminou" como resultado — cada uma lê o histórico de execução ou o estado de um item, e compara com um número esperado.
# provas.sh — cinco medicoes; nenhuma aceita "parece que funcionou"
PROJETO=ffv-lab; REGIAO=us-east-1
MAQUINA=$(aws stepfunctions list-state-machines \
--query "stateMachines[?name=='${PROJETO}-pedidos'].stateMachineArn" --output text)
# ── Prova 1: erro transitorio E retried, com backoff crescente ───────────────
EXEC=$(aws stepfunctions start-execution --state-machine-arn "$MAQUINA" \
--name "prova-transitorio-$(date +%s)" \
--input '{"pedidoId":"p-teste-1","forcarFalhaGateway":"transitorio-2x"}' \
--query executionArn --output text)
sleep 15
aws stepfunctions get-execution-history --execution-arn "$EXEC" \
--query "events[?type=='TaskFailed' || type=='TaskScheduled'].{tipo:type,quando:timestamp}" \
--output table
# Esperado: TaskScheduled para CobrarCartao aparece MAIS DE UMA VEZ, com o
# intervalo entre elas crescendo (2s, depois ~6s) — a prova do backoff.
# ── Prova 2: recusa definitiva NAO e retried ──────────────────────────────────
EXEC2=$(aws stepfunctions start-execution --state-machine-arn "$MAQUINA" \
--name "prova-recusa-$(date +%s)" \
--input '{"pedidoId":"p-teste-2","forcarFalhaGateway":"recusa-definitiva"}' \
--query executionArn --output text)
sleep 5
aws stepfunctions get-execution-history --execution-arn "$EXEC2" \
--query "length(events[?type=='TaskScheduled' && stateEnteredEventDetails.name=='CobrarCartao'])" \
--output text
# Esperado: 1 (uma tentativa so). Mais que 1 significa que CartaoRecusadoError
# nao esta com MaxAttempts=0, ou que o codigo nao esta lancando o erro certo.
# ── Prova 3: a compensacao rodou, e o estoque voltou ──────────────────────────
aws dynamodb get-item --table-name "${PROJETO}-pedidos" \
--key '{"sku":{"S":"sku-123"}}' --query 'Item.saldo.N' --output text
# Esperado: o saldo de ANTES da prova 2, sem a unidade que foi reservada e
# nunca cobrada. Numero diferente significa que LiberarEstoque nao rodou.
# ── Prova 4: o intervalo de espera bate com a formula (com o teto aplicado) ──
aws stepfunctions get-execution-history --execution-arn "$EXEC" \
--query "events[?type=='TaskScheduled' && stateEnteredEventDetails.name=='CobrarCartao'].timestamp" \
--output text
# Calcule a diferenca entre timestamps. Com IntervalSeconds=2, BackoffRate=3,
# MaxDelaySeconds=10: esperado ~2s antes da 2a tentativa, ~6s antes da 3a
# (18s calculado, sem cap ainda) — e o jitter torna cada execucao um pouco diferente.
# ── Prova 5: execucao duplicada com o mesmo nome e recusada ──────────────────
aws stepfunctions start-execution --state-machine-arn "$MAQUINA" \
--name "prova-duplicata" --input '{"pedidoId":"p-teste-3"}' >/dev/null
aws stepfunctions start-execution --state-machine-arn "$MAQUINA" \
--name "prova-duplicata" --input '{"pedidoId":"p-teste-3"}' \
&& echo "FALHA DA PROVA: a segunda chamada deveria ter sido recusada" \
|| echo "OK: ExecutionAlreadyExists — o nome da execucao e a idempotencia"
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Erro transitório é retried com backoff | `get-execution-history` filtrando `TaskScheduled` | mais de um agendamento de `CobrarCartao`, com intervalo crescente entre eles | só um agendamento indica que o erro não está casando com o retrier de `GatewayIndisponivelError` |
| 2 · Recusa definitiva NÃO é retried | contar `TaskScheduled` de `CobrarCartao` após injetar recusa | exatamente 1 | mais de 1 indica que `CartaoRecusadoError` está caindo no retrier genérico em vez do de `MaxAttempts: 0` |
| 3 · A compensação devolveu o estoque | `get-item` no saldo do SKU | saldo igual ao de antes da prova de recusa | saldo menor indica que `LiberarEstoque` não rodou, ou rodou e falhou silenciosamente |
| 4 · O intervalo bate com a fórmula | diferença entre timestamps de `TaskScheduled` | ~2s antes da 2ª tentativa, ~6s antes da 3ª (com jitter, aproximado) | intervalo constante desde a 1ª tentativa indica que `BackoffRate` não está sendo aplicado |
| 5 · Nome de execução impede duplicata | segundo `start-execution` com o mesmo `--name` | `ExecutionAlreadyExists` | se a segunda chamada tiver sucesso, o iniciador está gerando nomes diferentes por execução |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e as três produzem o mesmo sintoma superficial — "a execução não fez o que eu esperava". O que as separa é onde se olha.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| `States.ALL` fora do último catcher | coloque um catcher com `States.ALL` ANTES de um catcher específico no mesmo array | o catcher específico nunca é alcançado; todo erro cai no genérico | ordem dos objetos no array `Catch` da definição ASL | `States.ALL` sempre por último, e sozinho no `ErrorEquals` dele |
| `Catch` sem `ResultPath` | remova `ResultPath` do Catch de `CobrarCartao` | `LiberarEstoque` recebe só o erro como entrada e não sabe qual pedido ou SKU liberar | `stateEnteredEventDetails.input` do estado seguinte, no histórico de execução | `ResultPath: "$.erro"` preserva o restante do input e acrescenta o erro a ele |
| `BackoffRate` alto sem `MaxDelaySeconds` | suba `BackoffRate` para 5 e remova `MaxDelaySeconds` de `CobrarCartao` | a 4ª tentativa espera minutos, e ninguém decidiu isso de propósito | soma dos intervalos calculados a partir da política de Retry | `MaxDelaySeconds` sempre presente quando `BackoffRate` > 2, com o teto pensado a partir do SLA aceitável |
A falha que este laboratório não protege sozinho
Nenhuma das três falhas acima evita cobrança duplicada — essa proteção vem de uma chave de idempotência na CHAMADA ao gateway, dentro do código de `CobrarCartao`, e não existe campo no ASL que a substitua. Retry declarativo garante que o ESTADO vai rodar de novo; não garante que a ação por trás dele seja segura de repetir.
No estado CobrarCartao, o retrier de GatewayIndisponivelError tem IntervalSeconds: 2, BackoffRate: 3, MaxAttempts: 4 e MaxDelaySeconds: 10, sem jitter. Qual é o intervalo de espera antes da TERCEIRA tentativa de repetição (ou seja, depois da segunda falha)?
Segurança: o que uma máquina de estados expõe
A máquina de estados é código de negócio em formato declarativo, e herda os mesmos riscos de qualquer orquestrador: quem pode chamá-la, o que ela pode invocar, e o que fica gravado no histórico.
| Risco | Probabilidade | Impacto | Prevenção | Detecção | Resposta |
|---|---|---|---|---|---|
| Chamada ao gateway repetida sem chave de idempotência | média | alto | a chamada HTTP dentro do Lambda carrega um `Idempotency-Key` derivado do `pedidoId` | reconciliar cobranças do gateway com pedidos gravados | estornar a duplicata e corrigir a chamada |
| Papel de execução da máquina com `lambda:InvokeFunction` em `*` | média | alto | `Resource` restrito aos ARNs das 5 funções do fluxo | IAM Access Analyzer sobre uso real | apertar a política para os ARNs específicos |
| Dado sensível no input de um estado, com log `ALL` habilitado | baixa | alto | nunca passar número de cartão no input; usar token do gateway | busca por padrão de cartão no grupo de logs | rotacionar o token exposto e purgar o log |
| Execução iniciada fora do fluxo normal (StartExecution manual) | baixa | médio | política restringe `states:StartExecution` ao papel do iniciador | CloudTrail em `StartExecution` | revogar a credencial usada, investigar a origem |
| `LiberarEstoque` com permissão de escrever em qualquer item da tabela | média | médio | lógica e permissão condicionadas ao `pedidoId`/SKU do próprio evento recebido | revisão de código do handler | corrigir o escopo da chamada |
| Segredo do gateway na variável de ambiente do Lambda | média | alto | Secrets Manager, lido em runtime — nunca em `environment` | varredura de configuração do Lambda | rotacionar o segredo e mover para referência |
O `*` que não aparece na política deste laboratório, e por quê
Diferente do L03, nenhuma ação aqui é de conta inteira sem recurso específico: `lambda:InvokeFunction` aceita ARN de função, e `dynamodb:PutItem` aceita ARN de tabela. Não há linha para justificar com comentário — e se você ver um `Resource: "*"` em alguma cópia deste Terraform, é sinal de que alguém simplificou por pressa.
Observabilidade: as perguntas que o painel tem de responder
Um painel de orquestração tem uma função estreita: dizer se o fluxo está saudável e, quando não está, apontar em qual dos seis estados.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| Quantas execuções falharam de vez? | `ExecutionsFailed` (Sum) | algo sistemático quebrou — nem o Retry nem o Catch resolveram | > 5 em 5 min |
| Quanto tempo uma execução leva, do início ao fim? | `ExecutionTime` | subida indica gateway lento ou Retry consumindo tempo | p99 > 2× a linha de base |
| Em qual estado as execuções estão morrendo? | histórico agrupado por `stateEnteredEventDetails.name` nas falhas | aponta exatamente qual dos 6 estados, sem grep em log de texto | investigação, sem limiar fixo |
| Quantas tentativas de retry, em média, por execução? | contagem de `TaskFailed` seguido de `TaskScheduled` no histórico | retry alto e constante indica instabilidade do gateway, não acaso | > 2 tentativas médias por execução |
| Quantos pedidos foram compensados? | contagem de entradas em `LiberarEstoque` | número que financeiro cruza com a taxa de recusa do gateway | comparar com taxa de recusa esperada |
| A máquina está sendo limitada (throttled)? | `ThrottledEvents` | a taxa de transição de estado da conta foi atingida | qualquer valor > 0 |
| Quem iniciou execuções fora do fluxo normal? | CloudTrail em `StartExecution` | execução manual em produção, fora do iniciador | qualquer identidade fora da esperada |
A métrica que some quando você troca para Express
Histórico de execução completo no console é recurso de Standard. Um Express Workflow só mostra o que você mandou para o CloudWatch Logs via `logging_configuration` — sem isso habilitado, a pergunta "em qual estado morreu" não tem resposta nenhuma.
Escala: 10, 10 mil, 1 milhão, e falha de AZ
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| Menos de 1 pedido/s (atual) | 6 transições por execução, custo desprezível | nada | nada |
| Pico de campanha, 5 a 10× | mais execuções concorrentes, mesma forma | a taxa de INÍCIO de execução da conta não é o limite real; o gateway externo é | nada muda na máquina; considerar fila própria diante do gateway fica fora de escopo aqui |
| 10 mil pedidos/dia constante | custo por transição começa a aparecer na fatura | 6 transições × 10 mil = 60 mil transições/dia | ainda desprezível; medir antes de otimizar |
| 1 milhão de pedidos/dia | custo por transição domina; taxa de transição da conta pode ser tocada | Standard cobra por transição, inclusive as de Retry | avaliar Express para os passos genuinamente idempotentes; manter Standard só onde não pode |
| Pico durante indisponibilidade do gateway | todas as execuções em `CobrarCartao` entram em Retry ao mesmo tempo | sem `JitterStrategy`, todas tentam de novo no mesmo instante — retry storm contra um serviço já fraco | `JitterStrategy: FULL` espalha as tentativas dentro da janela calculada |
| Falha de AZ | Step Functions e Lambda são serviços regionais gerenciados pela AWS | não há AZ para o cliente escolher nestes dois serviços | o que fica exposto é a dependência do gateway de terceiro e do DynamoDB — cobertos nos labs de banco |
O gargalo real não está na máquina
Em nenhum dos cenários acima o limite prático é a taxa de execução do Step Functions — 2.000 execuções/s em Standard é muito acima de qualquer volume plausível da Cadência. O gargalo é sempre o gateway de pagamento de terceiro, que o desenho deste módulo não controla.
Custo: o que este laboratório acrescenta à fatura
A troca de arquitetura não acrescenta recurso com hora ligada — acrescenta uma dimensão de cobrança nova (transição de estado) e mais algumas funções Lambda pequenas.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | algumas execuções por dia | poucas transições e poucas invocações Lambda | desprezível | nenhuma; otimizar aqui é gastar atenção onde não há dinheiro |
| Produção pequena (volume atual) | menos de 1 pedido/s | 6 transições por pedido, mais 5 invocações Lambda pequenas | baixa e previsível | nenhuma ação necessária; o custo aqui é dominado pelo gateway externo, não pela AWS |
| Alta escala | 1 milhão de pedidos/dia | transições de estado tornam-se linha visível; retries multiplicam a contagem | crescente com o volume | mover passos idempotentes (ex.: NotificarCliente) para Express, mantendo CobrarCartao em Standard |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Transições de estado (Standard) | cada transição, inclusive as de Retry | uma execução com 3 tentativas de retry em CobrarCartao soma mais transições que uma sem falha |
| Execução (Express) | número de execuções, duração e memória | não é a dimensão usada aqui — citada para contraste com Standard |
| Invocação de cada Lambda | por invocação + duração | cobrada à parte da máquina; 5 funções pequenas custam pouco cada uma, mas somam |
| CloudWatch Logs da máquina | GB ingerido e retido | `level: ALL` com `include_execution_data` grava o payload inteiro de cada estado |
| DynamoDB (estoque e pedidos falhos) | unidades de leitura/escrita ou modo sob demanda | já existe desde o L22; este módulo acrescenta a tabela de falhas |
O que não aparece em nenhuma linha da fatura AWS
Reduzir o tempo de diagnóstico de um incidente de "abrir o código e ler 400 linhas" para "abrir uma execução no console e ver em qual estado ela parou" não tem preço na AWS. Ele aparece na folha de pagamento, no tempo que duas pessoas gastam investigando.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | histórico de execução por estado, sem depender de log de texto | só a cobrança tem compensação nomeada | saga completa nos demais passos não idempotentes (L35) | média |
| Segurança | papel da máquina restrito aos ARNs do fluxo; segredo do gateway fora do código | chamada ao gateway ainda sem chave de idempotência própria | idempotency key derivada do pedidoId na chamada HTTP | alta |
| Confiabilidade | erro transitório e terminal tratados por políticas diferentes; execução nomeada evita duplicata | gateway de terceiro continua sendo o ponto único de falha externo | circuit breaker no cliente HTTP do Lambda (L36) | média |
| Eficiência de performance | passos pequenos, fáceis de otimizar isoladamente | ainda todos em Standard; nem todo passo precisa dessa garantia | avaliar Express para NotificarCliente quando o volume justificar | baixa |
| Otimização de custos | transições cobradas só pelo que roda; sem recurso com hora ligada parada | em alto volume, transições de Retry somam mais que o esperado | medir a taxa real de retry do gateway antes de ajustar MaxAttempts | baixa |
| Sustentabilidade | funções pequenas, memória dimensionada por passo | cinco funções separadas em vez de uma podem gerar mais cold start agregado | medir cold start real antes de otimizar memória às cegas | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de forma de orquestrar. Cada nível resolve um risco e compra outro.
Onde a Cadência estava até este laboratório: try/catch crescendo a cada requisito novo, sem visibilidade por passo.Step Functions Standard com 4 Lambdas pequenas e um Catch de compensação para o único passo não idempotente.Compensação testada para TODOS os passos não idempotentes, não só a cobrança — é o L35.Passos genuinamente idempotentes migram para Express; a cobrança permanece em Standard.Catálogo de máquinas de estado reaproveitáveis entre times, versionadas e com aprovação de mudança.Histórico de execuções — sucesso, falha por estado, tempo de cada Retry — vira dado para priorizar revisão humana das compensações.A ordem não é negociável, e o motivo é concreto
Migrar para Express no nível 4 sem ter passado pelo nível 2 significa decidir idempotência por passo sem antes ter nomeado os erros de cada um — que é o que o nível 2 força a fazer. Pular direto para "mais barato" sem essa base reintroduz exatamente o risco de duplicata que motivou escolher Standard aqui.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. A pergunta "o que acontece se o passo X falhar" tem resposta determinística: um campo `Retry` ou `Catch` no JSON. Um modelo não melhora isso — é configuração, não previsão.
Há um lugar onde IA acrescentaria valor real, e ele é modesto: hoje toda recusa de cobrança aciona a mesma compensação, sem distinção. Um classificador sobre o histórico de execuções — motivo da recusa, valor do pedido, histórico do cliente — poderia sinalizar quais compensações merecem revisão humana antes de liberar o estoque de novo para venda, em vez de todas seguirem automáticas.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | priorizar quais recusas de cobrança merecem revisão humana, em vez de tratar todas como o mesmo evento |
| Por que uma regra não bastaria? | uma regra bastaria para começar: "valor acima de X → revisão manual" cobre a maior parte. IA só se justifica depois que a regra simples mostrar seu limite |
| De onde viriam os dados? | o histórico de execução do Step Functions e o motivo de recusa do gateway — tudo já existe, sem coleta nova |
| Qual o risco? | aprender de poucos exemplos de fraude real e liberar revisão de um caso que precisava de atenção; exige avaliação com dado retido e caminho manual sempre disponível |
| Por que não agora? | porque a Cadência processa menos de 1 pedido por segundo — volume de recusas insuficiente para treinar qualquer coisa com confiança |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "ler o histórico de execução e dizer se o pedido está OK" troca um sinal determinístico — o estado final da execução, exato — por um probabilístico. Onde existe um `Catch` apontando para um estado nomeado, um modelo só acrescenta latência e a chance de errar com confiança.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Um Lambda "cola" que só invoca outras funções em sequência, fingindo orquestração | parece uma migração incremental e menos arriscada | perde exactly-once e histórico nativo; ganha só mais uma camada de indireção sobre o mesmo problema | "o que houve com o pedido X" continua exigindo ler código, só que de duas funções em vez de uma | a orquestração declarada no ASL, não reimplementada em código | nunca; se vai orquestrar, orquestre na ferramenta feita para isso |
| `States.ALL` não como último catcher do array | parece "pega tudo, resolve depois" | catchers declarados depois dele nunca são avaliados, e o roteamento erra em silêncio | erro específico que deveria ir para um estado de compensação cai no fallback genérico | `States.ALL` sempre por último, sozinho no `ErrorEquals` | nunca; é regra do schema, não estilo |
| Um `Catch` genérico único para toda a máquina, sem distinguir por passo | menos JSON para escrever | perde exatamente a informação que motivou trocar de arquitetura — "o que houve" some de novo | todo erro cai no mesmo estado de tratamento, sem saber de qual passo veio | Catch por estado, ou por grupo de estados com o mesmo tipo de compensação | fluxo com 1 ou 2 estados, onde a distinção não agrega |
| Confiar que Retry por si resolve chamada não idempotente ao gateway | parece que "tentar de novo" é seguro por definição | cobrança duplicada se a chamada HTTP não carregar uma chave de idempotência própria | duas cobranças no gateway para um pedido só, sem erro nenhum na máquina | Retry no estado MAIS idempotency key na chamada HTTP — as duas coisas, não uma | nunca para ações que movem dinheiro; aceitável para leitura pura |
| Copiar `BackoffRate` e `MaxAttempts` de outro módulo sem medir o gateway real | o número já existe em outro laboratório, funciona lá | intervalo curto demais vira retry storm contra um serviço já instável; longo demais atrasa sem necessidade | gateway recebendo rajadas de tentativa simultâneas durante instabilidade | medir o SLA do gateway e derivar os valores dele, com `JitterStrategy: FULL` | como ponto de partida, revisado antes de produção |
| Usar Express "porque é mais barato" sem checar idempotência de cada passo | a tabela de preço por transição chama atenção | at-least-once pode cobrar duas vezes se `CobrarCartao` não tiver proteção própria | cobrança duplicada esporádica, sem padrão aparente | Standard para qualquer passo não idempotente; Express só onde cada passo aceita rodar mais de uma vez | volume alto, com auditoria da idempotência de cada passo já feita |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Execução falha na primeira tentativa de `CobrarCartao`, sem esperar nada | o erro casou com `CartaoRecusadoError`, que tem `MaxAttempts: 0` | ver o campo `cause` do evento `TaskFailed` no histórico | `get-execution-history`, evento mais recente antes do `Catch` | comportamento correto — se queria retry, o erro está classificado errado no Lambda |
| `CobrarCartao` tenta 4 vezes mesmo com o gateway fora do ar por uma hora inteira | `MaxDelaySeconds` limita o TETO de cada espera, não o número de tentativas | somar os tempos de espera da política de Retry configurada | campo `Retry` do estado, na definição ASL | aumentar `MaxAttempts` ou aceitar que a execução cai no `Catch` — que é o comportamento desejado |
| `LiberarEstoque` roda mas não sabe qual pedido ou quantidade liberar | `Catch` sem `ResultPath` sobrescreveu o input inteiro pelo erro | olhar o campo de entrada do estado no histórico | `stateEnteredEventDetails.input` de `LiberarEstoque` | `ResultPath: "$.erro"` preserva o restante do input |
| Duas execuções processando o mesmo pedido ao mesmo tempo | o iniciador não está usando o ID do pedido como nome da execução | `list-executions` filtrando pelo nome | chamada de `StartExecution` no código do iniciador | `name = pedidoId` — Standard rejeita duplicata em execução |
| Nada aparece no CloudWatch Logs da máquina de estados | `logging_configuration` não habilitada, ou nível abaixo de `ALL` | `describe-state-machine` e conferir o bloco de logging | campo `loggingConfiguration` do Terraform ou do console | nível `ALL`, com `include_execution_data` — cuidado com dado sensível no input |
| Lambda de um dos passos não é encontrada (`States.Runtime`) | ARN errado, ou alias/versão removida | comparar o `Resource` da definição ASL com `list-functions` | campo `Resource` do estado correspondente | apontar para um alias estável, não para uma versão numerada que pode ser removida |
| Execução trava em `RUNNING` por muito mais tempo que o esperado | uma Task sem `TimeoutSeconds`, esperando resposta que nunca chega | `describe-execution` para ver o estado atual | campo `currentStateName` da execução | definir `TimeoutSeconds` em cada Task, para que `States.Timeout` dispare e acione Retry/Catch |
| Alarme de execuções falhas nunca dispara, mesmo com falhas reais | `ExecutionsFailed` é métrica por máquina inteira, sem dimensão por estado | comparar a contagem do alarme com `list-executions --status-filter FAILED` | configuração do `aws_cloudwatch_metric_alarm` | conferir a dimensão `StateMachineArn` e o `namespace AWS/States` |
A pergunta que resolve metade destes casos
Antes de mexer em qualquer configuração, pergunte: o histórico de execução mostra o erro esperado, no estado esperado? Se a resposta já está lá e só não foi lida, o problema é investigação, não configuração — e é exatamente essa a vantagem que a orquestração declarativa comprou sobre o log de texto do monólito.
Limpeza: o que o destroy não leva
Este laboratório acrescenta poucos recursos com custo relevante, mas dois deles sobrevivem ao terraform destroy se você não conferir.
# 1. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 2. LOG GROUP da maquina de estados: tem retencao propria e pode nao
# pertencer ao ciclo de vida do recurso principal.
aws logs describe-log-groups \
--log-group-name-prefix "/aws/vendedlogs/states/ffv-lab" --query "logGroups"
aws logs delete-log-group \
--log-group-name "/aws/vendedlogs/states/ffv-lab-pedidos" 2>/dev/null || true
# 3. EXECUCOES EM ANDAMENTO: o destroy da maquina nao mata execucoes
# Standard rodando ha ate um ano. Pare-as antes, senao o Terraform
# pode falhar ou deixar execucao orfa cobrando transicao.
aws stepfunctions list-executions --state-machine-arn "$(terraform output -raw maquina_arn)" \
--status-filter RUNNING --query "executions[].executionArn" --output table
# 4. TABELAS DYNAMODB: se criadas com prevent_destroy ou fora deste modulo,
# confira que a tabela de pedidos-falhos nao ficou orfa.
aws dynamodb list-tables --query "TableNames[?contains(@, 'ffv-lab')]"
# 5. Prova final: nada com o nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Máquina de estados | sim | não | sem hora ligada; cobra só por transição realizada |
| Execuções em andamento (Standard, até 1 ano) | não | sim, por transição futura | o destroy não interrompe execução rodando; ela segue até terminar ou até o Terraform falhar |
| Log group da máquina | depende do provider/versão | sim, por retenção | tem ciclo de vida próprio, separado da máquina que o alimentava |
| Funções Lambda (5) | sim | não | sem hora ligada; cobram só por invocação |
| Tabela de pedidos falhos | sim, se em Terraform | sim, por armazenamento | confira se não foi criada fora do estado gerenciado |
| Alarme e tópico SNS | sim se em Terraform | centavos | alarme criado à mão no console não aparece no estado do Terraform |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| "O que acontece se o passo X falhar" exige ler 400 linhas | máquina de estados em ASL | a resposta vira um campo `Retry` ou `Catch`, visível ao lado do estado |
| Erro transitório e erro definitivo tratados igual | dois retriers tipados no mesmo estado | o primeiro que casa decide, e a ordem no array é a prioridade declarada |
| Cobrança pode rodar duas vezes | Standard Workflow (exactly-once) + nome de execução = pedidoId | idempotência de execução sem tabela extra; e o passo em si exige idempotência própria além disso |
| Estoque reservado nunca liberado se a cobrança falhar | estado `LiberarEstoque` via Catch nomeado | a compensação vira parte visível do fluxo, não um `catch` que só loga |
| Investigar um incidente exige grepar log de texto | histórico de execução nativo, por até 90 dias | cada transição — inclusive cada Retry — é um evento consultável, não uma linha solta |
- O iniciador recebe a mensagem da fila (do L22) e chama StartExecution com o ID do pedido.
- A máquina começa em ValidarPedido; erro aqui cai direto em RegistrarFalha.
- ReservarEstoque decrementa condicionalmente; falta de estoque não tenta de novo.
- CobrarCartao chama o gateway; erro transitório tenta de novo com backoff e teto.
- Recusa definitiva ou tentativas esgotadas acionam o Catch, que aponta para LiberarEstoque.
- LiberarEstoque devolve a unidade reservada e segue sempre para RegistrarFalha.
- Se as três Tasks do caminho feliz terminaram, NotificarCliente publica a confirmação.
- Todo o percurso — inclusive cada tentativa de retry — fica no histórico da execução.
Desafio — sem roteiro
O requisito
Uma regra de negócio nova precisa entrar no fluxo: pedidos acima de um valor limite exigem uma etapa extra de aprovação manual antes de prosseguir. Adicione um estado `Choice` que desvia para esse caminho.
Critério de aceite — executável, não "verifique se funciona"
Duas execuções de teste no console do Step Functions — uma com valor abaixo do limite (segue o caminho normal, sem aprovação) e uma acima (passa pelo estado de aprovação) — mostram o CAMINHO CERTO destacado no gráfico de cada uma.
- Dica 1: O estado `Choice` compara um campo do input contra o limite usando `NumericGreaterThan` — o campo tem que existir no JSON que chega até aquele ponto do fluxo, confira com o histórico de execução se não estiver.
- Dica 2: Aprovação manual dentro de uma máquina de estados normalmente usa o padrão "task token" (`waitForTaskToken`) — a execução PAUSA de verdade até alguém (ou outro sistema) devolver o token, não é só um passo que demora.
- Dica 3: Se as duas execuções de teste tomarem o mesmo caminho, o suspeito mais comum é o `Choice` sem uma condição `Default` bem definida, ou comparando o tipo errado (string contra número).
Lembrete de limpeza
Todo recurso que este desafio criar entra no mesmo `terraform destroy` do laboratório — nada fica para trás cobrando sozinho.
Perguntas frequentes
❓ Step Functions substitui completamente o try/catch no código?
❓ Qual a diferença entre Retry e Catch no Step Functions?
❓ Quando usar Express em vez de Standard?
❓ O que acontece se eu não colocar States.ALL por último num Catch?
❓ Retry no Step Functions garante que a chamada ao gateway não vai cobrar duas vezes?
❓ Preciso reescrever toda a aplicação para adotar Step Functions?
❓ Como eu vejo em qual passo uma execução falhou?
❓ Step Functions Standard é caro para poucos pedidos por segundo?
Fixando
A Cadência precisa que a cobrança do cartão execute exatamente uma vez por pedido, e que o time consiga auditar qualquer pedido dos últimos 60 dias direto no console. Qual tipo de workflow escolher, e por quê?
No estado ReservarEstoque, o array Retry tem, NESTA ORDEM: um retrier para EstoqueInsuficienteError com MaxAttempts: 0, seguido de um retrier para States.TaskFailed com MaxAttempts: 2. O Lambda lança EstoqueInsuficienteError. O que Step Functions faz?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L22 no ar (fila com DLQ e idempotência), C# / .NET 8 e Terraform básicos |
| Conhecimentos adquiridos | campos exatos de Retry e Catch em ASL; ordem de avaliação dos retriers; diferença Standard vs Express por semântica de execução e auditoria, não por preço; nome de execução como idempotência de graça |
| Limitação que fica | só a cobrança tem compensação nomeada; os demais passos não idempotentes ficam para o L35. E Retry declarativo não substitui idempotência na chamada HTTP em si |
| Próximo exemplo recomendado | L35 — Saga: transação distribuída sem 2PC. Reutiliza esta máquina de estados e estende compensação para os demais passos, com falha injetada em cada um |
| Também habilitado por este módulo | L76 (pipeline de ML de ponta a ponta, também orquestrado por Step Functions) parte do mesmo raciocínio de separar orquestração de código de passo |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Handling errors in Step Functions workflows — os campos exatos de Retry e Catch, valores padrão, e a ordem de avaliação dos retriers, que é a fonte do quiz sobre EstoqueInsuficienteError; e Choosing workflow type in Step Functions — a tabela comparativa de duração, semântica de execução e modelo de cobrança entre Standard e Express. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
O volume da Cadência (menos de 1 pedido/s, picos de 5/s) e os valores de IntervalSeconds, BackoffRate e MaxDelaySeconds usados na cobrança são os do cenário de exemplo, e servem como ordem de grandeza, não como referência. Derive `MaxDelaySeconds` do SLA que sua aplicação aceita para responder ao cliente, e `MaxAttempts` da taxa real de erro transitório do SEU gateway de pagamento — medida, não copiada deste módulo.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…