🇺🇸 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:
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:
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:
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):
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:
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:
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 vaziaUma 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ê:
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:
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 origemEsses 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:
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.ArnO 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.
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:
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:
# SDK v2 para JavaScript / AWS CLI v1 / config legada
export AWS_STS_REGIONAL_ENDPOINTS=regionalA 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