PTEN

Serverless não é à prova de falha: 7 padrões de resiliência com código

Padrões de resiliência para serverless na AWS com código TypeScript: retry com jitter, idempotência, timeout, circuit breaker e DLQ.

Autor
Will Peixoto
Publicado
03 nov 2026
Leitura
15 min
Idioma
PT

🇺🇸 Also available in English: Serverless Isn't Failure-Proof: 7 Resilience Patterns with Code

Deixa eu começar pela parte que dói: no outage de outubro de 2025 na us-east-1, muita aplicação 100% serverless ficou fora do ar. Lambda, DynamoDB, SQS, o pacote completo. E o que derrubou essas aplicações foi uma premissa bem comum: a de que usar serviço gerenciado equivale a ser imune a falha.

Serverless tira da sua mão o OS, o patching e a capacidade. Isso encolhe a sua superfície operacional. A superfície de risco continua lá, só que em outro lugar: na forma como o seu código reage quando uma dependência engasga. E se o código não foi desenhado para isso, a falha aparece justamente onde você não tinha plano.

Esse post é mão na massa: sete padrões de resiliência para serverless na AWS, cada um com código para colar e o “quando fazer e quando não”. Os exemplos estão em TypeScript com AWS SDK v3 e Powertools for AWS Lambda. Ele faz parte da série Arquitetura que Sobrevive: no pilar sobre o custo da resiliência eu falei da decisão estratégica; aqui a gente desce para o nível do código.

Por que serverless caiu no outage de outubro de 2025

Os detalhes estão no post-event summary que a própria AWS publicou. O gatilho foi uma race condition latente no sistema que gerencia o DNS do DynamoDB, que deixou vazio o registro do endpoint regional dynamodb.us-east-1.amazonaws.com. Sem resolução de DNS, tudo que dependia daquele endpoint parou de conseguir abrir conexão. Aí veio a cascata: a Lambda não conseguia criar nem atualizar funções e atrasou o processamento de SQS e Kinesis, o STS teve erro e latência, e até login no console fora da us-east-1 deu erro para quem usava credencial root.

Dois aprendizados que eu levo desse texto.

Multi-AZ não teria salvado. O que caiu foi um endpoint regional. Multi-AZ te protege da queda de uma Availability Zone; quando um serviço tropeça na região inteira, todas as AZs sentem juntas.

Fila acumulada e retry sem limite seguram o sistema no chão depois que a causa some. O summary mostra isso no próprio EC2: quando o DNS do DynamoDB voltou, um subsistema interno (o DWFM) tinha tanto trabalho represado que entrou em congestive collapse e não conseguia avançar. Os engenheiros precisaram fazer throttle e reiniciar hosts para destravar. Se acontece com um sistema da AWS, acontece com o seu.

Bora para os padrões.

1. Retry com exponential backoff e jitter

Retry é necessário. Retry agressivo é tiro no pé: se mil execuções tentam de novo na mesma hora, você cria um thundering herd que mantém a dependência no chão mesmo depois dela voltar. No Well-Architected isso é o REL05-BP03 do pilar de Reliability, “Control and limit retry calls”.

Primeira coisa: o SDK já faz retry com backoff e jitter. Não desligue. Deixe explícito o que você quer:

ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

const ddb = new DynamoDBClient({
  maxAttempts: 3,        // 1 tentativa + até 2 retries; não exagere
  retryMode: "standard", // o modo que a AWS recomenda como default
});

Existe também o retryMode: "adaptive", que adiciona um rate limiter do lado do cliente. A doc de retry dos SDKs indica ele para um caso específico (um cliente batendo num único recurso, tipo uma tabela, com throttling frequente) e avisa que ele pode atrasar até a primeira requisição. Para client compartilhado entre várias tabelas ou tenants, fique no standard.

Para retry da sua lógica (uma chamada HTTP a um serviço externo, por exemplo), use exponential backoff com full jitter, que espalha as tentativas no tempo. Sem jitter, todo mundo tenta de novo no mesmo milissegundo:

ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// retry só no que é transitório: timeout, throttling (429) e erro 5xx
const isRetryable = (err: unknown) => {
  const e = err as { name?: string; status?: number };
  return e.name === "TimeoutError" || e.status === 429 || (e.status ?? 0) >= 500;
};

async function withRetry<T>(
  fn: () => Promise<T>,
  { retries = 3, baseMs = 100, capMs = 2000 } = {}
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= retries || !isRetryable(err)) throw err;
      const expo = Math.min(capMs, baseMs * 2 ** attempt);
      const delay = Math.random() * expo; // full jitter
      await sleep(delay);
    }
  }
}

A explicação mais completa desse desenho está no artigo Timeouts, retries, and backoff with jitter, da Amazon Builders' Library.

Quando fazer: chamadas a dependências que podem dar throttle ou timeout.

Quando NÃO: não empilhe o seu retry em cima do retry do SDK (vira 3 × 3 = 9 tentativas e estoura o timeout da Lambda). E lembra que a Lambda tem vida curta: não adianta ficar tentando por 5 minutos numa função com timeout de 30s. Passou do limite, joga para uma fila e reprocessa depois (padrão 4).

2. Idempotência (para dar retry sem medo)

Retry só é seguro se a operação for idempotente: rodar duas vezes dá o mesmo resultado de rodar uma. Sem isso, retry vira cobrança dobrada, e-mail duplicado, estoque furado. E duplicata vai acontecer: fila standard do SQS entrega at-least-once, e a própria doc da Lambda avisa que, na invocação assíncrona (que é como o EventBridge chama a sua função), o mesmo evento pode chegar mais de uma vez. No Well-Architected, é o REL04-BP04, “Make mutating operations idempotent”.

O jeito mais limpo é o utilitário de idempotência do Powertools, que guarda o resultado da primeira execução numa tabela do DynamoDB e devolve esse resultado nas repetições:

ts
import { makeIdempotent, IdempotencyConfig } from "@aws-lambda-powertools/idempotency";
import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/dynamodb";

// tabela com partition key "id" e TTL no atributo "expiration"
const persistenceStore = new DynamoDBPersistenceLayer({ tableName: "idempotency" });

const config = new IdempotencyConfig({
  eventKeyJmesPath: "orderId", // a chave de idempotência sai só do orderId
  expiresAfterSeconds: 3600,   // o default também é 1 hora
});

export const handler = makeIdempotent(
  async (event: { orderId: string }) => {
    // roda uma vez por orderId; repetições devolvem o resultado guardado
    return await chargeOrder(event.orderId);
  },
  { persistenceStore, config }
);

Repara no eventKeyJmesPath. Sem ele, o Powertools usa o evento inteiro como chave: se a segunda entrega vier com um campo diferente (um timestamp, um id de trace), ela passa como se fosse nova. A chave tem que ser o identificador do negócio.

Sem Powertools, dá para fazer na unha com uma escrita condicional no DynamoDB (só a primeira passa):

ts
import { PutItemCommand, ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";

try {
  await ddb.send(new PutItemCommand({
    TableName: "orders",
    Item: { orderId: { S: orderId }, status: { S: "charged" } },
    ConditionExpression: "attribute_not_exists(orderId)", // 2ª vez falha de propósito
  }));
} catch (err) {
  if (!(err instanceof ConditionalCheckFailedException)) throw err;
  // já processado antes: segue sem cobrar de novo
}

Quando fazer: toda operação com efeito colateral que pode ser reentregue (pagamento, notificação, baixa de estoque).

3. Timeout curto e Circuit Breaker

Se uma dependência está lenta ou caída, a pior coisa é ficar pendurado esperando. Você segura a execução, estoura o timeout da Lambda, e a falha de um serviço vira a falha de todos.

Timeout curto e explícito em toda chamada externa é o mínimo (REL05-BP05, “Set client timeouts”). No SDK v3, com um detalhe das versões recentes:

ts
import { NodeHttpHandler } from "@smithy/node-http-handler";

const ddb = new DynamoDBClient({
  requestHandler: new NodeHttpHandler({
    connectionTimeout: 1000,     // 1s para abrir a conexão
    requestTimeout: 2000,        // 2s para a resposta
    throwOnRequestTimeout: true, // sem isso, estourar o requestTimeout só gera um warning
  }),
});

Os dois timeouts têm default 0, que desliga o limite. E nas versões atuais do @smithy/node-http-handler, passar do requestTimeout só escreve um warning no log, a menos que você ligue o throwOnRequestTimeout. Sem essa flag, o “timeout de 2s” do seu código não interrompe nada.

O passo seguinte é o Circuit Breaker: quando a taxa de erro passa de um limite, você “abre o circuito” e para de chamar a dependência por um tempo, respondendo rápido com um fallback. Com a lib opossum fica assim:

ts
import CircuitBreaker from "opossum";

const breaker = new CircuitBreaker(callRecommendations, {
  timeout: 2000,                // estoura se passar disso
  errorThresholdPercentage: 50, // 50% de erro na janela abre o circuito
  resetTimeout: 10000,          // depois de 10s, deixa uma chamada de teste passar
});
breaker.fallback(() => []); // sem recomendação, devolve lista vazia

Uma ressalva de arquitetura: na Lambda, o estado do breaker vive na memória de cada execution environment. Cem instâncias concorrentes são cem circuitos independentes, cada um aprendendo sozinho que a dependência caiu. Para proteger a sua Lambda de ficar pendurada, isso basta. Se a ideia é proteger a dependência de carga, combine com limite de concorrência ou com uma fila na frente.

Quando fazer: dependência que pode ficar lenta ou cair e que não é crítica. Abrir o circuito evita segurar a Lambda e propagar a lentidão para o resto.

4. Dead-letter queue, partial batch response e reprocessamento

Nem toda mensagem vai processar na primeira. Em vez de perder ou ficar em loop infinito, manda o que falhou repetidamente para uma DLQ e reprocessa depois, com calma.

Antes da DLQ, tem um ajuste que evita retrabalho: o partial batch response. Por padrão, se a sua Lambda falha num batch do SQS, o batch inteiro volta para a fila, incluindo as mensagens que já tinham sido processadas com sucesso. Com ReportBatchItemFailures, a função devolve só os ids que falharam. O utilitário de batch do Powertools faz essa contabilidade por você:

ts
import { BatchProcessor, EventType, processPartialResponse } from "@aws-lambda-powertools/batch";
import type { SQSHandler, SQSRecord } from "aws-lambda";

const processor = new BatchProcessor(EventType.SQS);

const recordHandler = async (record: SQSRecord): Promise<void> => {
  const order = JSON.parse(record.body);
  await processOrder(order); // se lançar erro, só ESTA mensagem volta para a fila
};

export const handler: SQSHandler = async (event, context) =>
  processPartialResponse(event, recordHandler, processor, { context });

Se todas as mensagens do batch falharem, o Powertools lança erro e o batch inteiro volta, como sem o recurso. E o partial batch response só funciona com a flag ligada no event source mapping. No Serverless Framework, fila, DLQ e flag ficam assim:

yaml
functions:
  processOrders:
    handler: src/orders.handler
    timeout: 30
    events:
      - sqs:
          arn: !GetAtt OrdersQueue.Arn
          batchSize: 10
          functionResponseType: ReportBatchItemFailures

resources:
  Resources:
    OrdersQueue:
      Type: AWS::SQS::Queue
      Properties:
        VisibilityTimeout: 180      # pelo menos 6x o timeout da função
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
          maxReceiveCount: 5        # 5 recebimentos e vai para a DLQ
    OrdersDLQ:
      Type: AWS::SQS::Queue
      Properties:
        MessageRetentionPeriod: 1209600 # 14 dias, mais que a fila de origem

Esses números vêm da doc da Lambda com SQS, que recomenda visibility timeout de pelo menos seis vezes o timeout da função e maxReceiveCount de pelo menos 5. A retenção maior na DLQ é porque, em fila standard, a expiração da mensagem conta a partir de quando ela entrou na fila original; com a mesma retenção, ela pode expirar logo depois de cair na DLQ.

Quando a Lambda é invocada de forma assíncrona (EventBridge, SNS, S3), quem segura a falha é a configuração de invocação assíncrona. Por padrão, a Lambda tenta mais duas vezes e descarta o evento depois disso. Para não perder nada, configure um on-failure destination:

yaml
functions:
  onOrderCreated:
    handler: src/notify.handler
    maximumRetryAttempts: 2   # de 0 a 2
    maximumEventAge: 3600     # de 60s a 6h
    destinations:
      onFailure:
        type: sqs
        arn: !GetAtt NotifyFailuresQueue.Arn

O destination tem vantagem sobre a DLQ clássica da função: além do evento, ele manda os detalhes da resposta (o erro) no registro. Lembre de dar à role da função permissão para enviar para o destino.

O mais importante: tenha um plano para a DLQ. Um alarme no CloudWatch em ApproximateNumberOfMessagesVisible da DLQ para te avisar, e um caminho de reprocessamento. O SQS tem redrive nativo da DLQ de volta para a fila de origem, pelo console ou pela API StartMessageMoveTask. Fila de mortos que ninguém olha vira cemitério de dados.

Se a sua fonte for um stream do Kinesis em vez de fila, a conversa muda: a ordem por shard é preservada e, por padrão, a Lambda reprocessa o batch com problema até ele passar ou os registros expirarem, segurando o shard nesse meio tempo. Escolher entre essas opções eu abri em Data Streaming na AWS: Kinesis, Firehose, Flink ou MSK?.

5. Graceful degradation em vez de tela branca

Nem tudo precisa cair junto. Se a recomendação está fora, mostra o catálogo sem recomendação. Resposta degradada quase sempre ganha do erro 500. Na prática é separar o crítico do enfeite e dar fallback para o enfeite, que é exatamente o REL05-BP01 do Well-Architected: transformar dependência hard em soft.

ts
async function getProductPage(id: string) {
  const product = await getProduct(id); // crítico: se falhar, falha mesmo

  let recommendations: Product[] = [];
  try {
    // com fallback configurado, o breaker já devolve [] quando o circuito abre
    recommendations = await breaker.fire(id);
  } catch {
    recommendations = []; // rede de segurança: degrada sem derrubar a página
  }

  return { product, recommendations };
}

Decidir o que é crítico e o que é enfeite é resiliência seletiva, e eu dediquei uma seção do pilar da série a isso. O usuário prefere um site mais simples a um site fora do ar.

6. Entenda regional vs zonal (e onde o Multi-AZ para)

Multi-AZ resolve a queda de uma AZ. Só que a maioria dos endpoints de serviço gerenciado é regional, e o DynamoDB de outubro de 2025 mostrou o que isso significa: o serviço tropeçou na região, e estar espalhado em três AZs não mudou nada.

Esse padrão é uma pergunta honesta, sem snippet: quais dos seus componentes são SPOF regional? Se o negócio exige sobreviver à queda de uma região inteira, aí entra multi-região (DynamoDB Global Tables, failover de rota, etc.), com o custo e a complexidade que eu detalho no pilar. E repara no que o próprio summary registrou: quem tinha Global Tables conseguia ler e escrever nas réplicas de outras regiões, mas viu a replicação com a us-east-1 atrasar bastante. Multi-região também tem bula, então ela entra onde o RTO/RPO justifica.

Se a tentação for pular direto para uma segunda cloud, faz a conta antes: em Multi-cloud te deixa menos resiliente, não mais eu mostro por que, na maioria dos casos, multi-região na mesma cloud resolve o problema real com uma fração da complexidade.

7. Não amarre o control plane crítico na us-east-1

Mesmo quem rodava em outra região sentiu outubro de 2025. O summary conta que login no console com credencial root deu erro em regiões fora da us-east-1, e que o Redshift de todas as regiões ficou sem conseguir usar credencial de IAM user para rodar queries, por uma dependência de API do IAM na us-east-1.

Parte disso você não controla. O STS você controla. No SDK v3 para JavaScript, o STS usa endpoint regional sempre, mas com uma pegadinha: se nenhuma region estiver configurada, ele cai no endpoint regional da us-east-1. Dentro da Lambda, a variável AWS_REGION já resolve. Em container, script ou pipeline de CI, deixe explícito:

ts
import { STSClient } from "@aws-sdk/client-sts";

// SDK v3: endpoint regional sempre; sem region, cai em us-east-1
const sts = new STSClient({ region: "sa-east-1" });

O SDK para JavaScript v2 e o AWS CLI v1 ainda têm default legacy, que manda as chamadas para o endpoint global sts.amazonaws.com. Desde abril de 2025, a AWS atende o endpoint global na própria região para as regiões habilitadas por padrão, mas continua recomendando o endpoint regional sempre que possível. Em config legada, force:

bash
# SDK v2 para JavaScript / AWS CLI v1 / config legada
export AWS_STS_REGIONAL_ENDPOINTS=regional

A tabela completa de defaults por SDK está na doc de STS regionalized endpoints. E aproveite para mapear quais dependências suas passam pela us-east-1 sem você ter pedido: login, federação, pipeline, DNS. A dependência fora do mapa é a que não entra no plano de contingência.

O que você leva

Serviço gerenciado te entrega muita resiliência de graça. Mas a resiliência que importa, a que te mantém de pé quando a AWS tropeça, não vem do serverless. Vem da arquitetura que você desenha em cima dele. Como eu disse no pilar: não existe arquitetura à prova de falhas, existe organização à prova de surpresas. No código, isso vira linha:

  • Retry com backoff e full jitter, com limite, e sem empilhar o seu retry no do SDK.

  • Idempotência com a chave certa (eventKeyJmesPath), porque a duplicata vai chegar.

  • Timeout explícito que de fato lança erro (throwOnRequestTimeout), e Circuit Breaker na dependência que não é crítica.

  • Partial batch response no SQS, DLQ com alarme e redrive, e on-failure destination na invocação assíncrona.

  • Graceful degradation para o enfeite, e um mapa honesto dos seus SPOFs regionais e das dependências escondidas na us-east-1.

Comenta qual desses padrões já te salvou (ou já te queimou por não ter). E se você usa Powertools, me conta quais utilitários entraram no seu kit. Se o post te ajudou, manda aquele joinha e compartilha com o time que está desenhando a próxima fila. Valeu demais!

BUILD. SCALE. REPEAT. =D

Will Peixoto

Will Peixoto é Staff Engineer na Serverless Guru e escreve sobre serverless, arquitetura e agentes de IA na AWS: o que ele escolheria, por quê e quanto custa.

Mais da série

Post novo direto no seu email

Um tema de serverless, arquitetura ou agentes de IA na AWS por edição: o que eu escolheria, por quê e quanto custa. Zero spam.

Abre a página de inscrição numa nova aba.