Blog

  • Post-mortem: o retry que transformou um timeout de 3 segundos em duas horas de banco esgotado

    Um endpoint de terceiro passou a responder em 3,2 segundos onde antes respondia em 180 ms. O timeout do cliente HTTP estava configurado em 3 segundos. Quarenta minutos depois, o pool de conexões do banco primário estava saturado e a aplicação devolvia 503 em rotas que nem tocavam naquele integrador. O terceiro nunca saiu do ar — apenas degradou. Quem caiu foi o sistema que o consumia.

    O cenário abaixo é reconstruído como exercício, não como incidente real, mas o encadeamento é o mais frequente em aplicações com fila de jobs e retry automático: uma dependência lenta vira indisponibilidade total porque a política de repetição amplifica carga em vez de absorvê-la. Vale destrinchar cada elo, porque o elo que a maioria das equipes corrige primeiro é o que menos importa.

    Linha do tempo do incidente

    T+0 — a latência p99 do endpoint de validação de documentos sobe de 180 ms para 3,2 s. Nenhum alerta dispara: o monitor de disponibilidade do integrador continua verde, porque ele responde 200.

    T+2min — os workers que processam a fila de validação começam a estourar o timeout de 3 s. A política de retry configurada no framework é a padrão: três tentativas, intervalo fixo de 5 segundos. Cada job que antes ocupava um worker por 200 ms agora ocupa por aproximadamente 24 segundos — três tentativas de 3 s mais duas esperas de 5 s, somando o overhead de serialização.

    T+6min — o throughput efetivo da fila cai para cerca de um centésimo do normal, mas a taxa de entrada não muda. A fila cresce. O autoscaler, configurado para reagir a profundidade de fila, sobe de 8 para 40 workers.

    T+9min — aqui o incidente deixa de ser sobre o integrador. Cada worker abre uma conexão com o banco no início do job, consulta o registro, chama o terceiro, e só devolve a conexão depois de gravar o resultado. A conexão fica retida durante toda a chamada HTTP. Com 40 workers segurando conexão por ~24 s cada, o pool de 50 conexões do primário fica permanentemente ocupado.

    T+11min — requisições web, que compartilham o mesmo pool, passam a esperar na fila de aquisição de conexão. O timeout de aquisição é de 30 s. As respostas HTTP da aplicação começam a passar de 30 s e o balanceador marca as instâncias como não saudáveis.

    T+14min — 503 generalizado. A partir daqui, o tráfego retomado por clientes que também fazem retry acrescenta uma segunda onda de amplificação.

    Por que o retry de intervalo fixo piora o quadro

    Retry com intervalo fixo tem duas propriedades ruins sob degradação. A primeira é multiplicativa: com três tentativas, a carga enviada ao serviço lento é o triplo da carga original, exatamente no momento em que ele tem menos capacidade. Se a causa da lentidão for saturação do lado do terceiro — e em geral é —, a política de retry impede a recuperação.

    A segunda é a sincronização. Jobs que falharam na mesma janela voltam a tentar na mesma janela, porque todos esperam os mesmos 5 segundos. O padrão de chegada, que era razoavelmente uniforme, vira uma sequência de rajadas. Esse é o efeito conhecido como thundering herd: a carga não só aumenta, ela se concentra em picos que o dimensionamento do destino não previu.

    A correção é exponential backoff com jitter — a espera cresce a cada tentativa e recebe um componente aleatório que desfaz a sincronização. A variante full jitter costuma ser a melhor escolha para chamadas idempotentes, porque distribui as tentativas por todo o intervalo em vez de agrupá-las perto do teto:

    import random, time
    
    BASE = 0.2      # 200 ms
    CAP  = 20.0     # teto de espera
    MAX_ATTEMPTS = 4
    
    def call_with_backoff(fn):
        for attempt in range(MAX_ATTEMPTS):
            try:
                return fn()
            except TransientError:
                if attempt == MAX_ATTEMPTS - 1:
                    raise
                ceiling = min(CAP, BASE * (2 ** attempt))
                time.sleep(random.uniform(0, ceiling))
    

    Uma observação que costuma passar batida: backoff resolve a concentração, não o volume total nem o tempo de ocupação do worker. Um job que tenta quatro vezes com teto de 20 s pode ficar retido bem mais que os 24 s do incidente. Backoff sem limite de orçamento de tempo troca um problema por outro.

    Defina um orçamento de tempo por job, não só um número de tentativas. Em Python: deadline = time.monotonic() + 8 no início e, antes de cada nova tentativa, if time.monotonic() + espera > deadline: raise. O job falha rápido e volta para a fila em vez de segurar recurso indefinidamente.

    O erro de arquitetura que causou o dano real

    Nada do que foi descrito até aqui explica por que a página de login caiu. O retry ruim degradou a fila de validação; isso seria um incidente contido. O que espalhou o problema foi a conexão de banco retida durante a chamada de rede externa.

    É um padrão sutil porque o código parece correto. Uma transação aberta no início do handler, uma chamada externa no meio, um commit no fim — a leitura linear sugere consistência. Na prática, o tempo de vida da conexão passa a ser governado pela latência de um sistema sobre o qual a equipe não tem controle algum.

    A regra prática é direta: nenhuma conexão de banco deve estar aberta enquanto uma chamada de rede externa está em voo. Isso significa três blocos separados — ler e fechar, chamar o terceiro, reabrir e gravar. Quando a operação precisa ser atômica em relação ao efeito externo, o caminho é outbox ou máquina de estados persistida, não transação longa.

    O segundo agravante é o pool compartilhado. Tráfego web e processamento assíncrono têm perfis de risco diferentes: o job pode esperar, a requisição do usuário não. Compartilhar o mesmo pool significa que qualquer patologia no assíncrono se propaga para o síncrono. Separar em pools distintos com limites próprios é o bulkhead clássico, e é barato de implementar — costuma ser uma string de conexão adicional e um limite menor para os workers.

    As correções em ordem de retorno

    Nem todas valem o mesmo. Em ordem de impacto por esforço:

    Correção Esforço O que resolve O que não resolve
    Separar pool de workers e pool web Baixo Impede que a fila derrube o tráfego de usuário A fila continua parando quando o terceiro degrada
    Fechar conexão antes da chamada externa Médio Corta a raiz da retenção; reduz uso de pool em ordens de grandeza Exige revisar atomicidade e tratar gravação parcial
    Backoff exponencial com jitter e orçamento de tempo Baixo Elimina a rajada sincronizada e a amplificação 3x Continua tentando contra um destino já saturado
    Circuit breaker por destino Médio Para de chamar depois de N falhas e falha rápido Precisa de meia-abertura bem calibrada para não oscilar
    Reduzir timeout do cliente para p99 + margem Baixo Devolve o worker mais cedo Gera falso negativo se a margem for apertada demais

    Se houver espaço para apenas duas neste trimestre, as duas são separar os pools e fechar a conexão antes da chamada externa. Backoff e circuit breaker melhoram o comportamento sob falha; essas duas impedem que a falha atravesse a fronteira entre subsistemas. A diferença entre um incidente de fila e um incidente de plataforma está aí.

    O que instrumentar para ver antes

    O incidente ficou invisível por nove minutos porque a única métrica observada do integrador era disponibilidade. Quatro sinais teriam antecipado o diagnóstico:

    • Latência p99 por dependência externa, não média — a média de 180 ms mascara uma cauda de 3 s por muito tempo.
    • Conexões em uso sobre o tamanho do pool, com alerta na faixa de 70% a 80%, não em 100%.
    • Tempo de espera na aquisição de conexão. É o indicador que sobe primeiro e o que menos aparece em dashboard.
    • Taxa de retry por destino. Uma razão de tentativas por job que sai de ~1,0 e vai para 2,5 descreve o incidente inteiro em um gráfico.

    Por onde começar: abrir o código dos jobs que chamam serviços externos e localizar cada trecho em que uma conexão de banco está viva durante a chamada. Essa auditoria costuma render uma lista curta, entre três e dez pontos, e cada ponto corrigido reduz a superfície de propagação de qualquer degradação futura — inclusive das que ainda não têm nome.

  • Decisão de arquitetura — Sessão no servidor ou JWT: qual modelo de autenticação para qual aplicação

    A decisão aparece cedo em todo projeto novo e quase nunca é revisitada: o servidor guarda o estado da sessão e devolve um identificador opaco, ou emite um token assinado que carrega as claims e não precisa de consulta posterior. As duas abordagens funcionam. O que muda é o custo de revogar acesso, o comportamento sob múltiplos serviços e a superfície de ataque quando o token vaza.

    O erro comum não é escolher errado — é escolher JWT por reflexo, tratar como stateless de verdade e descobrir na primeira demissão de funcionário que não existe botão para invalidar o token dele.

    O que cada modelo realmente faz

    Na sessão em servidor, o backend cria um registro (em Redis, Postgres ou memória), gera um identificador aleatório sem significado e devolve num cookie. Toda requisição autenticada faz uma leitura desse registro. O estado da verdade fica no servidor; o cliente carrega só a chave.

    No modelo JWT, o servidor assina um payload com sub, exp, papéis e o que mais for necessário. A validação é uma verificação de assinatura local — nenhum acesso a banco. O estado da verdade fica no token, congelado no instante da emissão.

    Essa última frase é a origem de praticamente todos os problemas de JWT em produção. Um token com validade de 24 horas afirma, por 24 horas, algo que era verdade quando foi emitido. Se o usuário perdeu o papel de admin há dez minutos, o token não sabe.

    // sessão: uma leitura por requisição, verdade sempre atual
    const session = await store.get(cookie.sid);
    if (!session) return unauthorized();
    
    // JWT: verificação local, verdade congelada na emissão
    const claims = jwt.verify(bearer, publicKey);
    // claims.role pode estar desatualizado desde o login
    

    Revogação é o critério que separa os dois

    Com sessão em servidor, revogar é apagar uma linha. Encerrar todas as sessões de um usuário é apagar as linhas com aquele user_id. Deslogar de todos os dispositivos, forçar re-login após troca de senha, cortar acesso de alguém desligado da empresa — tudo é a mesma operação trivial, com efeito imediato.

    Com JWT, revogar exige uma denylist consultada a cada requisição. E uma denylist consultada a cada requisição é uma sessão em servidor com passos extras: voltou o acesso ao banco, voltou o ponto único de estado, e ainda sobrou a complexidade de gerenciar chaves de assinatura e rotação.

    A saída praticada é o par access token curto mais refresh token. O access vive de 5 a 15 minutos e é validado localmente; o refresh vive dias, fica no servidor e é revogável. Funciona — mas note o que aconteceu: voltou o estado no servidor, só que agora com dois tipos de credencial, duas validades e um fluxo de renovação para depurar. O ganho é que a janela de exposição de um token comprometido cai para minutos.

    Se o refresh token está guardado em banco e é consultado a cada renovação, o sistema é stateful. Chamar a arquitetura de stateless nesse ponto só atrapalha quem for debugar depois.

    Onde o token fica é mais importante que o formato

    Boa parte das discussões entre sessão e JWT confunde formato da credencial com meio de transporte. São eixos independentes: dá para colocar JWT em cookie e dá para mandar identificador de sessão em header.

    O que decide a superfície de ataque é o armazenamento no cliente. Token em localStorage é legível por qualquer script que rode na página — uma dependência npm comprometida basta para exfiltrar. Cookie com HttpOnly, Secure e SameSite=Lax não é acessível por JavaScript, ao custo de exigir proteção contra CSRF nas requisições que alteram estado.

    Set-Cookie: sid=<opaco>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
    

    Para aplicação web servida no mesmo domínio da API, cookie HttpOnly é a opção mais segura e a mais simples de acertar. Para app mobile nativo, cookies não ajudam: o token vai no Keychain ou no Keystore e viaja em header Authorization. Esse é um caso em que JWT resolve melhor, não por ser stateless, mas porque o cliente não tem gerenciamento de cookie nem origem para amarrar.

    Quando o JWT compensa de verdade

    O argumento sólido a favor do token assinado não é performance — é atravessar fronteira de confiança sem chamada de volta.

    Em arquitetura com vários serviços, um serviço que recebe uma requisição precisa saber quem é o chamador. Com sessão centralizada, cada serviço consulta o serviço de identidade, ou consulta o Redis compartilhado. Com token assinado, cada serviço valida a assinatura com a chave pública e segue. Some a isso um gateway que já validou na borda e propaga as claims internamente, e o desenho fica limpo.

    A economia de latência isolada raramente justifica a escolha: uma leitura em Redis na mesma rede custa algo na casa de um milissegundo, enquanto verificar assinatura RS256 custa dezenas de microssegundos. A diferença só aparece em volume alto ou quando a alternativa é uma chamada HTTP entre serviços, não uma leitura em cache.

    O outro caso legítimo é federação: OIDC, login social, SSO corporativo. Aí o formato do token não é escolha do projeto — é o que o provedor emite.

    Critério Sessão no servidor JWT
    Revogação imediata Nativa, apagar registro Exige denylist ou access curto
    Mudança de papel reflete Na requisição seguinte Só na renovação
    Custo por requisição Leitura em cache (~1 ms) Verificação local (~dezenas de µs)
    Múltiplos serviços Estado compartilhado Validação independente
    Ponto único de falha Store de sessão Chave e rotação
    Complexidade operacional Baixa Média a alta
    Cliente mobile nativo Desconfortável Natural

    Os erros que aparecem em revisão de código

    Alguns são recorrentes o bastante para virar item de checklist.

    Aceitar o algoritmo declarado no header do token. Bibliotecas antigas honravam alg vindo do próprio JWT, o que permite trocar por none ou rebaixar RS256 para HS256 usando a chave pública como segredo HMAC. A validação deve fixar o algoritmo esperado, nunca lê-lo do token.

    Guardar dado mutável nas claims. Plano contratado, limite de crédito e permissões finas envelhecem dentro do token. Vale carregar apenas identificador e papéis grosseiros, buscando o resto no serviço dono do dado.

    Validade longa por conveniência de UX. Token de 30 dias para não incomodar o usuário transforma qualquer vazamento em acesso de 30 dias. O caminho é access curto com renovação silenciosa, não expiração distante.

    Payload inchado. Um JWT com lista completa de permissões vira um header de vários kilobytes repetido em toda requisição, inclusive nas de asset.

    Não planejar rotação de chave. Sem kid no header e sem endpoint JWKS, trocar a chave assinadora significa derrubar todo mundo. Isso precisa existir antes do incidente que obriga a rotação.

    A recomendação

    Para aplicação web monolítica ou com poucos serviços, servida no mesmo domínio da API: sessão no servidor, identificador opaco em cookie HttpOnly, store em Redis com fallback no banco. É menos código, revogação é grátis, e a superfície de ataque é menor. A escala em que isso vira gargalo está muito acima do ponto em que a maioria dos sistemas chega.

    Para app mobile, API pública com clientes de terceiros, ou malha de serviços que precisam validar identidade sem consultar um ponto central: JWT com access token de 10 a 15 minutos, refresh token opaco e revogável guardado no servidor, rotação de refresh a cada uso e detecção de reuso para invalidar a família inteira de tokens.

    Modelo híbrido — sessão para o painel web, JWT para o app e para integrações — é decisão defensável e comum, desde que a revogação seja centralizada num único lugar. Duas fontes de verdade sobre quem está logado é o começo do próximo incidente.

    Se o sistema já está em produção com JWT de validade longa e sem revogação, o próximo passo é encurtar o access token e introduzir o refresh revogável antes de qualquer refatoração maior. É a mudança que reduz mais risco por linha de código alterada.

  • Connection pooling no Postgres: por que o pool dentro da aplicação para de funcionar quando o deploy vira serverless

    O sintoma aparece sempre do mesmo jeito: a aplicação sobe em containers com autoscaling ou em funções serverless, o tráfego cresce um pouco acima do normal e o Postgres começa a devolver FATAL: remaining connection slots are reserved for non-replication superuser connections. O pool está configurado. O max do pool está baixo. E mesmo assim o banco recusa conexão.

    O problema não é o tamanho do pool. É o fato de que existe um pool por processo, e a quantidade de processos deixou de ser uma constante.

    O pool da aplicação assume um mundo que não existe mais

    HikariCP, node-postgres, SQLAlchemy, database/sql: todos implementam o mesmo modelo. O processo abre N conexões TCP com o banco, mantém essas conexões vivas e distribui entre as threads ou entre as tasks do event loop. O ganho é evitar o handshake — em Postgres, cada conexão nova custa a criação de um processo backend no servidor, e isso não é barato.

    Esse modelo funciona quando o número de processos da aplicação é conhecido e estável. Três instâncias com pool de 20 são 60 conexões, e a conta fecha. Quando o orquestrador escala de 3 para 30 réplicas em resposta a um pico, viram 600 conexões contra um banco cujo max_connections padrão é 100. O pool cumpriu exatamente o que foi configurado para fazer — o problema é que ninguém configurou o agregado.

    Em serverless o quadro é pior, porque a unidade de execução não é a réplica: é a invocação. Cada ambiente de execução frio abre a própria conexão, e o pool interno perde a função — não há concorrência dentro de uma invocação para amortizar o custo. O pool vira um wrapper caro em volta de uma conexão só, e a soma de ambientes quentes vira um número que ninguém controla diretamente.

    A conta que define o teto real

    Aumentar max_connections é a primeira reação e quase sempre a errada. Cada conexão no Postgres é um processo do sistema operacional, com stack, catálogo em cache e área de trabalho própria. O custo por conexão ociosa é da ordem de alguns megabytes e varia bastante com o schema e com o padrão de consultas — mas o número que costuma estourar não é esse.

    O que estoura é work_mem. Ele é alocado por operação de ordenação ou hash dentro de cada query, não por conexão. Uma consulta com três joins em hash e um sort pode consumir várias vezes o valor configurado. Multiplicar isso por 600 conexões ativas é a diferença entre um banco lento e um banco com OOM kill.

    Há ainda o custo de escalonamento: com centenas de backends ativos, o Postgres passa a gastar tempo relevante em contenção de lightweight locks e em troca de contexto. O throughput cai antes de o banco ficar sem memória. É comum um banco entregar mais transações por segundo com 40 conexões ativas do que com 400.

    Antes de mexer em qualquer parâmetro, meça de onde vêm as conexões:
    SELECT state, application_name, count(*) FROM pg_stat_activity GROUP BY 1,2 ORDER BY 3 DESC;
    Se a maior fatia estiver em idle — e não em idle in transaction ou active — o gargalo é de pooling, não de carga.

    Pooler externo: onde ele resolve e onde ele quebra o código

    A correção estrutural é mover o pool para fora do processo. Um pooler externo — PgBouncer, pgcat, ou o pooler gerenciado do provedor — aceita centenas de conexões dos clientes e multiplexa sobre uma dezena de conexões reais com o banco. O ponto de decisão é o modo de pooling, e ele tem consequência direta no código da aplicação.

    Modo Conexão real é devolvida Multiplexação O que deixa de funcionar
    session ao cliente desconectar praticamente nenhuma nada — mas também não resolve o problema
    transaction ao fim de cada transação alta, é o modo útil SET de sessão, LISTEN/NOTIFY, advisory locks de sessão, tabelas temporárias, cursores fora de transação
    statement ao fim de cada comando máxima transações com múltiplos comandos — inviável para a maioria das aplicações

    O modo transaction é o único que entrega o ganho procurado, e é onde a maior parte das migrações trava. Código que executa SET search_path logo após conectar e assume que aquilo vale para as queries seguintes vai falhar de forma intermitente: a próxima query pode cair em outra conexão real. O mesmo vale para isolamento multi-tenant baseado em SET app.tenant_id combinado com row level security — o padrão precisa migrar para SET LOCAL dentro da transação, ou o vazamento entre tenants é questão de tempo.

    Advisory locks são o caso mais silencioso. pg_advisory_lock mantém o lock atrelado à sessão; em transaction pooling a sessão pertence ao pooler, não à aplicação. A variante pg_advisory_xact_lock resolve, porque o lock morre com a transação — mas isso muda a semântica de qualquer lock que precisava sobreviver a mais de uma transação.

    Prepared statements e o atrito com o driver

    O segundo bloqueio típico são prepared statements no protocolo estendido. O driver prepara uma vez, recebe um nome de statement e reutiliza. Em transaction pooling, a próxima execução pode cair numa conexão real que nunca viu aquele statement, e o resultado é o erro prepared statement "S_1" does not exist — intermitente, proporcional à taxa de reuso de conexão, e por isso quase impossível de reproduzir em staging com pouca carga.

    Há dois caminhos. O primeiro é desligar o cache de prepared statements no driver — em JDBC, zerando prepareThreshold; em drivers Python e Node, desabilitando o modo estendido ou o cache nomeado. Custa CPU no banco, porque cada execução volta a passar por parse e plan, e o impacto real depende de quanto do workload são queries curtas e repetidas.

    O segundo é usar uma versão de pooler que rastreia prepared statements por conexão de cliente e os replica na conexão real quando necessário. O PgBouncer suporta isso no modo transaction a partir da série 1.21, através do parâmetro max_prepared_statements — que vem desligado por padrão e precisa ser definido explicitamente. Poolers mais novos já nascem com o comportamento. Essa é a opção a escolher quando ela estiver disponível na versão em uso: preserva o ganho de plano em cache sem obrigar a aplicação a abrir mão do protocolo estendido.

    Serverless muda a resposta, não o problema

    Em ambiente serverless o pooler externo continua sendo a resposta certa, com uma ressalva: ele precisa estar do lado do banco, não do lado da função. Um pooler embutido no runtime da função não multiplexa nada — cada ambiente de execução tem o seu.

    Quando a latência de rede entre a função e o pooler é alta, ou quando o modelo de execução não sustenta conexões TCP persistentes, a alternativa é um proxy de dados sobre HTTP: a função faz uma requisição, o proxy mantém as conexões reais com o Postgres. Perde-se transação multi-comando com estado do lado do cliente e ganha-se independência do ciclo de vida da conexão. Vale para workloads de leitura curta e escrita simples; não vale quando existem transações longas com lógica no meio.

    Uma decisão que aparece pouco e economiza muito: separar o pool de leitura do de escrita. Réplicas de leitura com um pooler próprio absorvem a maior parte da concorrência e mantêm o primário com um número pequeno de conexões — que é exatamente onde a contenção dói.

    Como dimensionar o pool real do pooler

    Definido o modo, resta o número que importa: quantas conexões o pooler mantém abertas contra o banco. É o parâmetro default_pool_size no PgBouncer, e ele costuma ser configurado alto demais por reflexo — o que devolve o problema original com uma camada a mais no caminho.

    A heurística clássica do ecossistema Postgres é dimensionar pelo paralelismo real do servidor, algo como o dobro do número de núcleos somado ao paralelismo do armazenamento, e não pelo número de clientes. Para workload OLTP em servidor de 8 núcleos, isso costuma cair entre 20 e 30 conexões ativas — número que soa baixo até a primeira medição mostrar que a latência média cai quando o valor diminui.

    Duas armadilhas de configuração aparecem depois: o teto agregado precisa considerar todas as instâncias do pooler, porque dois poolers com pool de 25 são 50 conexões no banco; e a soma de default_pool_size por par usuário/base pode ultrapassar max_client_conn sem que nada avise até o pico. O parâmetro reserve_pool_size existe justamente para absorver rajadas curtas sem inflar o pool base.

    Uma exceção precisa ficar documentada: aplicações que dependem de LISTEN/NOTIFY exigem uma conexão dedicada fora do pooler. É uma linha de configuração — e o custo de não fazê-la é descobrir em produção que as notificações passaram a chegar de forma intermitente, sem erro em log nenhum.

  • Cobrança por uso em SaaS: o checklist de medição que precisa existir antes do preço

    A decisão de cobrar por consumo costuma nascer numa reunião comercial e chegar ao time técnico como um requisito de faturamento. É a ordem errada. Sem instrumentação confiável antes da mudança de preço, o que se entrega é uma fatura que o cliente contesta, um suporte que não consegue explicar o número e uma receita que o financeiro se recusa a reconhecer.

    O trabalho técnico de cobrança por uso não é integrar um gateway. É construir uma cadeia de medição auditável entre o evento que acontece no produto e a linha que aparece no boleto. Cada elo dessa cadeia tem um modo de falha conhecido, e todos eles se manifestam como divergência de valor no fim do mês.

    Instrumentar antes de precificar, sempre nessa ordem

    Antes de escolher o valor da unidade, é preciso ter pelo menos um ciclo completo de faturamento medindo em modo sombra: a métrica é coletada, agregada e exibida internamente, mas ninguém é cobrado por ela. Esse período existe para responder três perguntas que nenhuma planilha responde.

    A primeira é qual a distribuição real de consumo entre os tenants. Em quase toda base madura, a curva é fortemente assimétrica: uma minoria de contas responde pela maior parte do volume, e a mediana fica muito abaixo da média. Precificar pela média produz um plano que é caro demais para o corpo da base e barato demais para quem realmente consome.

    A segunda é qual a variação intra-mês. Se um tenant oscila entre 200 e 2.000 chamadas por dia, o modelo precisa de teto, franquia ou média móvel — cobrar o pico gera contestação garantida.

    A terceira é qual a taxa de perda de eventos do pipeline atual. Se a coleta perde 2% dos registros em picos de carga, esse número precisa ser conhecido e reduzido antes de virar dinheiro. Divergência de medição vira crédito em conta, e crédito em conta vira desconfiança permanente no número.

    O modo sombra só é útil se o número medido ficar visível para o time de suporte durante o ciclo. É o suporte que descobre, olhando caso a caso, que o contador está somando as requisições de health check do próprio monitoramento do cliente.

    A unidade de medida é uma decisão de arquitetura, não de marketing

    Existe uma tentação de cobrar pela unidade que é fácil de explicar comercialmente — usuário ativo, documento processado, requisição. O critério correto é diferente: a unidade deve ser aquela que se correlaciona com o custo marginal de servir o cliente e que o cliente consegue controlar.

    Quando a unidade não se correlaciona com custo, a margem some no cliente que consome muito por unidade cobrada. Quando o cliente não consegue controlá-la, ele sente a cobrança como arbitrária e passa a contestar toda variação.

    Unidade Correlação com custo Controle pelo cliente Risco principal
    Usuário ativo no mês Fraca em produtos de processamento Alto Compartilhamento de login para reduzir a conta
    Requisição de API Boa em produtos de leitura Médio Retry automático inflar a contagem
    Registro armazenado Boa quando o custo é de storage Baixo — dado antigo não se apaga sozinho Cliente descobre a conta só quando ela já cresceu
    Job concluído Forte em processamento assíncrono Alto Definir o que conta como concluído em caso de falha parcial

    Entre as quatro, a mais defensável em produto de processamento é o job concluído, porque alinha o que o cliente pagou ao que ele recebeu. O preço a pagar por essa escolha é ter de definir por contrato o comportamento em falha: job que estourou timeout no meio, job que processou 80% dos itens e abortou, job reprocessado por erro da plataforma. Cada um desses casos precisa de uma regra escrita antes do lançamento, não depois da primeira contestação.

    A cadeia de medição e onde ela quebra

    O caminho do evento até a fatura tem quatro estágios, e cada estágio precisa de uma garantia explícita.

    Emissão. O evento é gerado no ponto do código onde o trabalho de fato aconteceu, não onde a requisição chegou. Emitir na borda conta tentativa; emitir na conclusão conta entrega. A emissão deve carregar identificador do tenant, tipo de unidade, quantidade, timestamp do evento e um identificador único de deduplicação.

    Transporte. Filas com garantia de entrega ao menos uma vez são a escolha padrão, o que transfere para o consumidor a responsabilidade de deduplicar. Descartar evento em caso de fila cheia é uma decisão que precisa ser consciente e monitorada — perder medição significa faturar a menos, e faturar a menos silenciosamente é pior do que uma falha ruidosa.

    Agregação. Os eventos brutos são somados por tenant, por unidade e por janela. O erro comum aqui é agregar destrutivamente: somar e descartar o detalhe. Sem o evento bruto retido por um período que cubra pelo menos dois ciclos de faturamento mais o prazo de contestação contratual, não existe forma de auditar uma conta questionada.

    Fechamento. A janela do ciclo precisa de um período de carência antes do fechamento definitivo, para absorver eventos atrasados por reprocessamento de fila. Um evento que chega depois do fechamento cai no ciclo seguinte com o timestamp original preservado — nunca é descartado nem recontado no ciclo já emitido.

    Idempotência: o único requisito não negociável

    Em pipeline com entrega ao menos uma vez, o mesmo evento chega mais de uma vez com frequência previsível. Se a agregação for um incremento cego, a conta do cliente cresce sozinha depois de qualquer redeploy do consumidor.

    A defesa é uma chave de idempotência gerada na emissão e verificada na ingestão, com a inserção do registro e a atualização do agregado dentro da mesma transação:

    -- ingestao de evento de uso, idempotente por event_id
    BEGIN;
    
    INSERT INTO uso_evento (event_id, tenant_id, unidade, quantidade, ocorrido_em)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (event_id) DO NOTHING;
    
    -- so agrega se a linha acima foi de fato inserida
    UPDATE uso_agregado a
       SET quantidade = a.quantidade + $4
      FROM (SELECT 1 FROM uso_evento WHERE event_id = $1 AND inserido_em > now() - interval '1 second') novo
     WHERE a.tenant_id = $2 AND a.unidade = $3 AND a.ciclo = $6;
    
    COMMIT;

    A variação mais robusta em volume alto é abrir mão do agregado incremental e recalcular a soma sob demanda a partir da tabela de eventos, com índice composto por tenant, unidade e janela. Custa mais leitura, mas elimina a classe inteira de bugs em que o agregado e o detalhe divergem e ninguém sabe qual dos dois está certo.

    A chave de idempotência deve ser derivada do trabalho executado — identificador do job, por exemplo — e não gerada aleatoriamente no momento da emissão. Chave aleatória não protege contra reemissão pelo produtor, apenas contra reentrega pelo transporte.

    O que precisa estar de pé antes de virar a chave

    Cobrança por uso muda a relação do cliente com o produto: ele passa a olhar o consumo. Se o produto não oferece essa visibilidade, o suporte vira o painel de consumo, atendendo uma por uma.

    Três coisas precisam existir no dia do lançamento. Uma tela de consumo no próprio produto, atualizada com defasagem de no máximo algumas horas e com detalhamento suficiente para o cliente identificar qual integração ou usuário puxou o volume. Um alerta configurável quando o consumo do ciclo atinge um percentual definido pelo cliente. E um endpoint de exportação do detalhe do período, porque cliente corporativo vai querer bater o número contra o próprio log antes de aprovar a fatura.

    Do lado interno, o requisito equivalente é reconciliação automática entre a soma dos eventos brutos, o agregado e o valor emitido na fatura, rodando todo dia com alerta em qualquer divergência acima de uma tolerância mínima. Divergência descoberta pelo cliente é incidente comercial; descoberta pela reconciliação é bug.

    O passo seguinte, com a medição estável e um ciclo sombra completo em mãos, é modelar o preço sobre a distribuição observada — definir a franquia no percentil que cobre o corpo da base e o preço marginal sobre o excedente, e simular a fatura de cada tenant existente antes de comunicar qualquer mudança.

  • Post-mortem hipotético: a chave de API que vazou no log e ficou 40 dias válida

    Uma chave de API de um gateway de pagamento aparece em texto puro numa linha de log de erro. O log vai para o agregador, o agregador retém 90 dias, e três times têm acesso de leitura ao índice. A chave só é revogada quarenta dias depois, quando alguém percebe o padrão numa busca por outra coisa. Não houve uso indevido detectado, o que é diferente de não ter havido uso indevido.

    Este post-mortem é hipotético, mas a sequência de falhas é banal o bastante para servir de checklist. O ponto não é a chave: é que quatro controles independentes deveriam ter interrompido a cadeia e nenhum interrompeu.

    A cadeia de falhas, na ordem em que aconteceu

    O serviço fazia uma chamada HTTP ao gateway. Diante de um erro 4xx, o cliente HTTP logava o objeto de request inteiro para facilitar depuração — incluindo o dicionário de headers. O Authorization estava ali dentro. Em operação normal isso nunca aparecia, porque erros 4xx do gateway eram raros. Uma mudança de contrato do lado do gateway passou a rejeitar um campo, os 4xx dispararam, e o header foi parar em milhares de linhas.

    A primeira falha é de design do cliente HTTP: logar o request completo é conveniente até o dia em que não é. A segunda é que o pipeline de ingestão de logs não tinha nenhuma regra de redação — nada procurava por padrões de secret na entrada. A terceira é que a credencial era estática, criada manualmente no painel do gateway dois anos antes, sem data de expiração e sem dono registrado. A quarta é que ninguém monitorava uso da chave por origem: se ela tivesse sido usada de outro IP, o alerta não existia para disparar.

    Repare que as quatro falhas são de camadas diferentes — aplicação, plataforma, gestão de identidade e detecção. É por isso que corrigir só a primeira não resolve. Um logger.debug mal colocado em outro serviço reabre o mesmo buraco na semana seguinte.

    Redação no ponto de saída, não no ponto de entrada

    A correção intuitiva é ir ao código que logou e remover o header. Funciona uma vez. A correção que escala é filtrar no ponto onde o log é serializado, de forma que nenhum caminho de código consiga emitir um campo sensível — mesmo um caminho escrito por alguém que não leu a convenção.

    Em Python, isso é um filtro no logger raiz; em Node, um serializer no pino ou equivalente. A lista de chaves proibidas é curta e deve ser tratada como configuração de plataforma, não de serviço.

    Filtro aplicado no logger raiz, não em cada chamada. Chaves comparadas em minúsculo para pegar Authorization, authorization e variações:

    REDACT = {"authorization", "cookie", "set-cookie", "x-api-key",
              "proxy-authorization", "password", "secret", "token"}
    
    class RedactFilter(logging.Filter):
        def filter(self, record):
            if isinstance(record.args, dict):
                record.args = {
                    k: ("[REDACTED]" if k.lower() in REDACT else v)
                    for k, v in record.args.items()
                }
            return True
    
    logging.getLogger().addFilter(RedactFilter())

    Duas ressalvas. A primeira: filtro por nome de chave não pega secret embutido no corpo de uma string já formatada — f"falhou: {request}" escapa de qualquer filtro estruturado. A regra que fecha esse caso é proibir interpolação de objetos de request em mensagem de log e passar contexto sempre como campo estruturado. A segunda: redação no cliente protege o que sai da aplicação, não o que já está armazenado. O expurgo do histórico no agregador é um trabalho separado, e a maioria dos agregadores não apaga por linha — apaga por índice inteiro.

    A camada que realmente resolve: credencial sem valor duradouro

    Redação reduz a chance de vazar. Credencial de vida curta reduz o valor do que vaza. As duas resolvem problemas diferentes e a segunda tem impacto maior, porque transforma um incidente de quarenta dias num incidente de quinze minutos.

    Modelo Janela de exposição Esforço de adoção Rastreabilidade
    Chave estática em variável de ambiente Até alguém revogar manualmente Nenhum Nenhuma — a chave não sabe quem a usou
    Secret manager com rotação agendada Até a próxima rotação (dias) Médio: exige recarregar credencial em runtime Parcial — versão da credencial identifica a janela
    Credencial efêmera via OIDC / STS Minutos Alto na primeira vez, baixo depois Alta — cada token carrega identidade da carga de trabalho

    A recomendação, quando o provedor externo suporta federação de identidade: credencial efêmera. Quando não suporta — e muitos gateways de pagamento e ERPs brasileiros ainda não suportam —, secret manager com rotação e um cliente que releia a credencial sem redeploy. O anti-padrão é adotar secret manager e continuar carregando o valor uma única vez no boot: a rotação existe no papel e a aplicação segue usando a versão antiga até reiniciar, o que na prática significa que a revogação derruba produção.

    Detecção: o alerta que faltava não era sobre o log

    Varredura de secret em código e em log é útil e barata — uma etapa de gitleaks ou equivalente no pipeline, mais uma regra de padrão no agregador que dispara quando uma linha casa com o formato de chave de um provedor conhecido. Vale ter. Mas é detecção de sintoma.

    O sinal com menor taxa de falso negativo é o uso da credencial fora do perfil esperado. Isso exige duas coisas que raramente estão prontas: saber de quais IPs ou identidades a chave deveria ser usada, e ter acesso ao log de uso do lado do provedor. Quando o provedor expõe esse log, uma regra simples — chamada originada de faixa não conhecida, ou volume fora do horário de operação — cobre o caso em que a chave vaza por um caminho que ninguém previu, incluindo o notebook de um contratado.

    Sem esse acesso, a alternativa honesta é encurtar a janela em vez de tentar detectar. Rotacionar a cada sete dias não impede o vazamento, mas garante que a resposta a “há quanto tempo isso está exposto?” tenha um teto conhecido. Um teto conhecido é o que permite dimensionar a resposta ao incidente sem adivinhar.

    Três correções tentadoras que não valem a prioridade

    Reescrever o histórico do repositório. Se o secret nunca esteve versionado — e neste caso não esteve, ele veio de variável de ambiente —, limpar histórico é trabalho sem retorno. Mesmo quando o secret está no Git, a reescrita só faz sentido depois da revogação, e ela não desfaz os forks e clones existentes. Revogar é o que resolve; limpar histórico é higiene posterior.

    Trocar o agregador de logs por um que ofereça mascaramento nativo. Migração de observabilidade consome trimestres e o mascaramento do lado do agregador roda depois da transmissão — o secret já saiu da rede da aplicação e já passou pelo agente de coleta. A redação precisa acontecer antes, no processo que gera a linha.

    Bloquear logger.debug em produção via política. A tentação é tratar o incidente como indisciplina e responder com regra. O header vazou num log de erro, não de debug, e teria vazado igual com nível INFO. Controle que depende de alguém lembrar da regra falha na primeira semana em que o time está apertado — o filtro estrutural não depende.

    O padrão comum às três: são respostas ao evento específico, não à classe de falha. A pergunta que separa uma da outra é se a correção impediria um vazamento de um secret diferente, num serviço diferente, escrito por alguém que não participou do post-mortem.

    Ordem de execução e como medir

    As quatro correções não têm o mesmo custo nem o mesmo retorno. A ordem que faz sentido para a maioria dos times:

    1. Filtro de redação no logger de plataforma — horas de trabalho, cobre todos os serviços que usam a biblioteca comum.
    2. Inventário de credenciais externas com dono e data de criação — desconfortável, porque quase sempre revela chaves que ninguém sabe de onde vieram e cujo desligamento ninguém quer testar.
    3. Rotação automatizada com releitura em runtime — a parte cara é o releitura, não a rotação.
    4. Federação de identidade onde o provedor suportar — migração por serviço, não big bang.

    Três métricas dizem se o trabalho pegou, e nenhuma delas é “número de vazamentos”, que é raro demais para servir de sinal. A idade mediana das credenciais externas ativas — se não cai mês a mês, a rotação não está rodando de verdade. O percentual de credenciais com dono identificado, que deveria caminhar para 100% antes de qualquer automação. E o tempo entre a decisão de revogar e a revogação efetiva em produção, medido num exercício deliberado: escolher uma credencial, revogar, e cronometrar. Times que nunca fizeram esse exercício costumam descobrir que o número está em horas, não em minutos, e quase sempre por causa de um serviço que só relê o secret no boot.

    O próximo passo concreto, se nada disso existe hoje: rodar o inventário. Sem saber quantas credenciais externas estão ativas e quem responde por cada uma, as outras três correções são exercício de arquitetura sobre um sistema imaginário.

  • Sessão no servidor ou JWT: onde o token stateless deixa de compensar

    Um usuário clica em “encerrar sessão em todos os dispositivos” e continua autenticado por mais quarenta minutos. O suporte abre chamado, alguém vai no código e descobre que sessão nenhuma existe do lado do servidor: o que existe é um JWT assinado com expiração de uma hora e nenhum lugar onde marcá-lo como inválido. A escolha entre sessão no servidor e token stateless raramente é sobre desempenho. É sobre quem detém a autoridade para encerrar um acesso antes da hora.

    O que cada modelo guarda, e onde

    Na sessão no servidor, o cliente recebe um identificador opaco — uma string aleatória sem significado próprio — dentro de um cookie HttpOnly. Todo o estado relevante (id do usuário, tenant, papéis, timestamp de emissão, fingerprint do dispositivo) fica num Redis, num Postgres ou onde o time preferir. A cada requisição o servidor faz uma leitura por chave para descobrir quem está do outro lado.

    No JWT, a ordem se inverte. O token carrega as próprias claims em Base64URL e uma assinatura no fim: header.payload.signature. O servidor não consulta nada — verifica a assinatura com uma chave que já tem em memória e confia no conteúdo. O estado mora no cliente.

    Duas consequências dessa inversão são frequentemente ignoradas em revisão de código. A primeira: o payload não é criptografado, só assinado. Base64URL é codificação, não proteção. E-mail, plano contratado, id interno de tenant e papel do usuário ficam legíveis para qualquer pessoa que copie o token do DevTools. A segunda: como não há consulta, também não há ponto onde interceptar. Um token emitido às 9h com exp às 10h vale até as 10h, tenha o usuário sido demitido, tido o plano cancelado ou perdido o notebook às 9h05.

    Revogação é o eixo real da decisão

    Com sessão no servidor, revogar é apagar um registro. DEL session:9f3a... e a próxima requisição daquele cookie falha. Encerrar todas as sessões de um usuário é apagar um conjunto. O custo é uma leitura por requisição, e essa leitura é exatamente o que dá ao servidor o poder de dizer não.

    Com JWT, as saídas conhecidas são três, e nenhuma sai de graça.

    Expiração curta. Reduzir exp para 5 ou 10 minutos encolhe a janela de exposição, mas transfere o problema para o refresh: alguma coisa precisa emitir o próximo token, e essa coisa consulta um estado persistido. O modelo stateless volta a ter estado, só que num ponto menos óbvio.

    Denylist de tokens revogados. Funciona, e é a opção mais escolhida. Também é a que merece mais atenção: se cada requisição precisa checar se o jti está numa lista negra, o sistema faz uma leitura por requisição para autenticar — que é precisamente o custo da sessão no servidor, agora com assinatura criptográfica e complexidade extra por cima. Vale a pena quando a denylist é pequena e rara. Não vale quando revogação é evento cotidiano.

    Rotação da chave de assinatura. Invalida tudo de uma vez. Serve para incidente de segurança, não para um usuário trocando de senha.

    Uma medida útil antes de escolher: registre o tempo entre o pedido de revogação e a primeira requisição negada. SELECT max(revoked_at - requested_at) FROM audit_revocations WHERE requested_at > now() - interval '30 days'. Se o número passa de alguns segundos e o produto lida com dado sensível, a discussão já tem resposta.

    O que cada modelo custa em infraestrutura

    A conta de latência costuma ser apresentada como se fosse decisiva, e quase nunca é. Uma leitura em Redis na mesma rede fica na casa de dezenas a poucas centenas de microssegundos. A verificação de assinatura HS256 fica abaixo disso; RS256 e EdDSA custam mais que HMAC, mas ainda na mesma ordem de grandeza. Numa requisição que termina consultando o banco de dados da aplicação, essa diferença desaparece no ruído.

    Dois custos menos citados importam mais.

    O primeiro é tamanho na rede. Um JWT com claims de tenant, papéis e permissões chega facilmente a algumas centenas de bytes, às vezes ultrapassa o quilobyte quando alguém decide embutir a lista de permissões inteira. Esse peso viaja em toda requisição, inclusive nas de asset e polling. Proxies reversos têm limite de tamanho de header — o padrão do Nginx fica em poucos kilobytes por header — e o sintoma clássico é um 431 que só aparece para o usuário com mais permissões da base.

    O segundo é operação. Sessão no servidor adiciona um componente com estado ao caminho crítico de autenticação: se o Redis cai, ninguém entra. Isso exige replicação, política de persistência decidida de forma consciente e um plano para o que acontece quando o cache é limpo (todos deslogam ao mesmo tempo, o que gera um pico de login que precisa caber na capacidade). JWT dispensa esse componente, e é aí que ele realmente ganha.

    Há ainda um custo que aparece no front-end e é debitado da conta de segurança. Sessão em cookie HttpOnly é inacessível a JavaScript por construção: um XSS na aplicação continua sendo grave, mas não entrega o identificador de sessão de bandeja. JWT guardado em localStorage — prática comum em SPA por ser o caminho de menor atrito — é legível por qualquer script que execute na página, incluindo uma dependência transitiva comprometida. Guardar o JWT em cookie HttpOnly resolve esse ponto, mas devolve o problema de CSRF que o header Authorization evitava, e obriga a decidir entre SameSite=Lax e token anti-CSRF explícito. Vale registrar: quem escolhe JWT por segurança e o coloca em localStorage costuma terminar com uma superfície maior do que a sessão que substituiu.

    Onde cada modelo ganha

    Critério Sessão no servidor JWT stateless
    Revogação imediata Nativa, uma exclusão Só com denylist ou janela de expiração
    Dependência de armazenamento Obrigatória no caminho crítico Nenhuma para validar
    Tamanho por requisição Dezenas de bytes Centenas de bytes a alguns KB
    Validação fora da aplicação (edge, gateway) Exige consulta ao backend Local, só com a chave pública
    Exposição de dados no cliente Nada legível Payload legível por quem tiver o token
    Sessão simultânea e limite de dispositivos Consulta direta ao conjunto Precisa de registro paralelo
    Auditoria de acesso ativo Lista disponível a qualquer momento Só por inferência de logs

    O padrão que se repete: JWT ganha onde a validação precisa acontecer longe de quem emitiu. Chamada entre serviços internos, autorização num API gateway ou numa função de edge, token de curta duração emitido por um provedor de identidade externo, API pública com escopos por integração. Nesses cenários, exigir round-trip ao backend de autenticação é justamente o que se quer evitar.

    Sessão no servidor ganha em aplicação web com cookie, painel administrativo, produto B2B com controle de acesso por administrador de conta e qualquer domínio em que “tirar alguém de dentro agora” seja requisito e não desejo — financeiro, saúde, RH, prontuário, folha.

    A recomendação, e o que fazer com o que já existe

    Para um SaaS web multi-tenant começando hoje: refresh token opaco em cookie HttpOnly, Secure e SameSite=Lax, com registro persistido por dispositivo, mais access token JWT de vida curta — 5 a 15 minutos — usado para autorizar as chamadas. Revogar significa apagar o registro do refresh; a janela residual é o tempo que sobra do access token, e essa janela é o preço explícito da escolha, não uma surpresa descoberta em incidente.

    Quinze minutos de janela é aceitável para a maioria dos produtos. Não é aceitável quando a ação revogável tem efeito financeiro irreversível. Nesse caso a saída não é encurtar mais o token: é validar a autorização crítica contra o estado atual no momento da operação, independentemente do que o token afirma. Token diz quem é; a operação sensível confere se ainda pode.

    Para quem já tem JWT puro em produção e sente a dor da revogação, a migração não precisa ser um big bang. Introduzir o endpoint de refresh com registro persistido, encurtar o exp do access token em etapas e só depois ligar a revogação real permite reverter em qualquer ponto. O indicador que fecha a migração é o mesmo que a justificou: a janela máxima entre pedido de revogação e negação efetiva, medida em produção, não estimada em reunião.

  • Post-mortem: o dashboard de BI que virou a maior linha da fatura em três dias

    Cenário: um data warehouse na nuvem rodando em uma região, a ferramenta de BI hospedada em outra, e um dashboard comercial com atualização automática a cada cinco minutos para cerca de quarenta pessoas. Numa sexta à tarde alguém trocou o filtro padrão do dashboard de “últimos 30 dias” para “todo o histórico”. Na segunda de manhã, a fatura parcial da conta acusava um valor de transferência de dados de saída maior que o custo mensal inteiro da infraestrutura — algo na casa das dezenas de milhares de reais acumuladas em pouco mais de setenta horas.

    O relato abaixo é reconstruído para ilustrar o modo de falha, não descreve a operação de nenhuma empresa específica e os valores servem apenas para dar ordem de grandeza. O padrão, porém, aparece em qualquer arranjo onde o motor de consulta e o consumidor dos dados vivem em zonas, regiões ou provedores diferentes — e ele não dispara nenhum alerta técnico enquanto está acontecendo.

    A conta chega três dias depois do erro

    O que torna esse tipo de incidente caro não é a magnitude do erro, é a defasagem entre causa e sinal. Uma queda de serviço se manifesta em segundos: o health check falha, o alerta dispara, alguém acorda. Um vazamento de custo se manifesta no fechamento da fatura, e a maioria dos provedores consolida o uso de rede com atraso de horas — em alguns casos, mais de um dia.

    Durante todo o fim de semana, cada métrica que o time observava estava verde. CPU do warehouse dentro da média, porque o motor era serverless e escalava sozinho. Latência do dashboard estável, porque a consulta continuava respondendo em poucos segundos. Taxa de erro em zero. Não houve degradação: o sistema fez exatamente o que foi pedido, quarenta vezes por hora, durante três dias, movendo um resultado de consulta inteiro pela internet pública a cada execução.

    A descoberta veio pelo financeiro, não pela engenharia. Esse detalhe é o diagnóstico real do incidente. Se o primeiro a perceber um problema de infraestrutura é quem paga a fatura, o problema não foi o filtro do dashboard — foi a ausência de qualquer sinal de custo dentro do ciclo de observabilidade do time.

    Há um agravante estrutural nesse tipo de defasagem: o console de faturamento da nuvem não é uma ferramenta de engenharia. Ele agrega por serviço e por conta, raramente por consulta, quase nunca por usuário. Quando o time finalmente abre o relatório de custo, encontra uma linha chamada “transferência de dados” com um valor grande e nenhuma pista de qual carga a produziu. Reconstruir a origem exige cruzar log de auditoria do warehouse com log de acesso do BI, o que costuma levar mais tempo que a correção em si.

    Egress é métrica de consulta, não de infraestrutura

    Times de plataforma instrumentam recursos: contêiner, nó, fila, banco. Transferência de saída não pertence a nenhum recurso. Ela é o produto de quatro variáveis que ninguém observa junto: tamanho do resultado da consulta, número de consumidores, frequência de atualização e distância de rede entre origem e destino.

    Nesse caso, as quatro pioraram ao mesmo tempo por causa de uma única mudança. Trocar a janela de tempo do filtro multiplicou o tamanho do resultado por duas ordens de grandeza. O cache da ferramenta de BI estava configurado por usuário e por combinação de filtros, então quarenta pessoas com o dashboard aberto significavam quarenta execuções independentes, não uma. O refresh automático de cinco minutos manteve o ciclo rodando à noite e no fim de semana, com as abas abertas em máquinas que ninguém desligou. E o cruzamento de região transformou o que seria tráfego interno gratuito em tráfego cobrado por gigabyte.

    Nenhuma dessas quatro decisões é errada isoladamente. Cache por usuário existe porque filtros diferentes produzem resultados diferentes. Refresh curto existe porque o time comercial queria ver pedido entrando. Separar BI e warehouse em regiões distintas costuma ser herança de migração, não escolha deliberada. O custo aparece na multiplicação, e a multiplicação não tem dono.

    Onde o custo se acumula sem aparecer

    Vale mapear os padrões recorrentes antes de discutir correção, porque o mesmo mecanismo se repete em contextos que nada têm a ver com BI.

    Padrão de acesso Por que vaza O que costuma resolver
    Dashboard com auto-refresh Resultado completo trafega por usuário, por ciclo, inclusive fora do horário comercial Cache compartilhado por consulta e refresh sob demanda em vez de temporizado
    Exportação agendada de CSV Job diário puxa a tabela inteira quando só o delta mudou Extração incremental por marca d’água de atualização
    Réplica entre regiões Replicação contínua de tabelas que ninguém lê na região secundária Replicar apenas o subconjunto efetivamente consultado
    Ambiente de staging apontando para produção Consultas de teste percorrem o mesmo caminho de rede cobrado Amostra local do dataset, não espelho remoto
    Serviço em nuvem A lendo storage em nuvem B Toda leitura é saída cobrada, mesmo interna à aplicação Colocar processamento junto do dado ou cachear na borda

    O denominador comum é o mesmo: alguém desenhou o fluxo assumindo que ler dado é barato. Dentro da mesma zona, quase é. Atravessando fronteira de rede, o preço por gigabyte multiplica por um volume que cresce sozinho conforme o negócio cresce.

    Vale notar o que não resolve: comprimir o resultado. Compressão reduz o volume trafegado por execução, mas não toca no número de execuções, que é o termo que cresce sozinho. Um resultado três vezes menor rodando quarenta vezes por hora continua sendo um problema de multiplicação. A ordem correta de ataque é sempre frequência primeiro, número de consumidores depois, tamanho por último — o inverso da intuição de quem chega ao problema pelo lado da rede.

    O que teria cortado o prejuízo no primeiro dia

    Três controles independentes, e qualquer um deles sozinho teria limitado o dano a algumas centenas de reais em vez de dezenas de milhares.

    O primeiro é orçamento com janela diária. A maioria dos times configura alerta de orçamento mensal com limiar percentual, o que significa descobrir o estouro quando já se gastou boa parte do mês. Um alerta sobre gasto acumulado nas últimas 24 horas comparado à média das duas semanas anteriores teria disparado no sábado de manhã.

    O segundo é teto de bytes por consulta no próprio motor. Vários warehouses permitem rejeitar a execução de qualquer consulta que ultrapasse um volume declarado de leitura, e isso é preferível a descobrir depois: o dashboard teria quebrado na sexta às 16h, com uma mensagem de erro clara e um responsável óbvio.

    Em BigQuery, o teto é uma propriedade do job e vale ser aplicado por padrão na conexão de BI, não caso a caso:

    bq query --maximum_bytes_billed=10000000000 \
      --use_legacy_sql=false \
      'SELECT ... FROM vendas WHERE data >= @inicio'

    Motores diferentes expõem o mesmo controle com outro nome — em Snowflake, o caminho é limitar o tamanho do warehouse virtual somado a STATEMENT_TIMEOUT_IN_SECONDS. O princípio não muda: falhar rápido custa menos que responder devagar.

    O terceiro é cache compartilhado no BI. Trocar o cache por usuário por cache por assinatura de consulta faz quarenta execuções virarem uma, e o ganho não é só de rede: reduz também a concorrência no warehouse no horário de pico. A objeção usual é controle de acesso por linha, que de fato impede compartilhar resultado entre perfis diferentes. Nesse caso, a solução é segmentar o cache por papel, não por pessoa.

    Próximo passo: os três guardrails que cabem numa sprint

    Se a operação tem qualquer fluxo de dados atravessando região ou provedor, três itens valem entrar no backlog agora, nessa ordem.

    • Inventário de travessias. Liste todo par origem-destino em que o dado sai de uma região e entra em outra. Bastam meia dúzia de perguntas ao time: onde está o warehouse, onde está o BI, onde estão os buckets, onde roda o ETL, onde ficam os backups.
    • Alerta de derivada, não de teto. Configure notificação sobre variação do gasto diário em relação à linha de base, e não apenas sobre o valor absoluto do mês. Vazamento de custo é sempre uma inflexão de curva antes de ser um número grande.
    • Limite duro no motor de consulta. Defina teto de bytes lidos ou timeout na conexão usada por ferramentas de terceiros. Ferramenta de BI é a superfície onde alguém sem contexto de infraestrutura consegue disparar uma consulta arbitrária.

    O item que costuma gerar mais resistência é o terceiro, porque quebra dashboard de gente que reclama alto. Vale quebrar mesmo assim. Um dashboard que só funciona lendo o histórico inteiro a cada cinco minutos não é um dashboard — é um job de ETL disfarçado, e deveria ser materializado como tabela agregada em vez de recalculado a cada abertura de aba.

  • Checklist de implementação: exclusão de conta e retenção de dados num SaaS multi-tenant

    Um cliente abre um chamado pedindo a exclusão da conta. O suporte marca deleted_at na tabela accounts, o login para de funcionar e o ticket é fechado. Três semanas depois o mesmo e-mail aparece num relatório de churn, num backup restaurado em staging e num índice de busca que ninguém lembrou de reprocessar. O dado não foi excluído — foi escondido da interface.

    Exclusão de conta em SaaS multi-tenant é um problema de inventário, não de comando SQL. Quem trata como DELETE FROM descobre o tamanho real do problema quando a primeira solicitação formal chega com prazo para resposta. O checklist abaixo cobre a implementação em quatro camadas: mapeamento, decisão por tipo de dado, propagação para sistemas satélites e prova de execução.

    O que “excluir a conta” significa em cada camada

    A confusão começa porque três operações diferentes carregam o mesmo nome. Desativar a conta bloqueia autenticação e mantém tudo. Anonimizar quebra o vínculo entre o registro e a pessoa, preservando o registro. Remover apaga a linha. Cada uma resolve um problema distinto e nenhuma resolve os três.

    Na prática, uma exclusão de conta real é uma combinação: alguns dados removidos, outros anonimizados, alguns preservados por obrigação legal ou contratual. Uma nota fiscal emitida não pode ser apagada porque o cliente pediu — mas o nome do usuário que a gerou pode ser substituído por um identificador opaco. Uma linha de log de auditoria que registra “usuário X alterou permissão de Y” perde sentido se o campo virar nulo, mas continua auditável se virar um hash estável.

    A decisão precisa ser tomada por categoria de dado, documentada, e refletida no código. Sem isso, cada engenheiro que encosta no fluxo aplica um critério próprio.

    Há ainda um efeito colateral típico de multi-tenant: o dado de um tenant referencia o dado de outro. Um usuário convidado que participa de dois workspaces, um recurso compartilhado entre organizações, um comentário feito por alguém de fora. Remover o tenant A sem checar essas bordas quebra a integridade do tenant B — ou, pior, vaza para o B a informação de que o A existiu. A regra prática é tratar identidade de usuário e associação a tenant como entidades separadas: a associação morre com o tenant, a identidade só morre quando não sobra nenhuma associação.

    Mapeie o dado antes de escrever o primeiro DELETE

    O inventário é a parte cara e a que costuma ser pulada. Num SaaS multi-tenant maduro, o dado de um tenant costuma estar em mais lugares do que o schema sugere:

    • Tabelas com tenant_id explícito — as fáceis, encontráveis por consulta ao catálogo do banco.
    • Tabelas ligadas por chave estrangeira indireta — anexos, comentários e eventos que referenciam um recurso que referencia o tenant.
    • Object storage — uploads, exportações geradas, PDFs, avatares. Costumam estar em prefixos por tenant, mas nem sempre.
    • Índices de busca e caches — Elasticsearch, Redis, cache de aplicação. Não seguem o ciclo de vida do banco.
    • Filas e dead-letter queues — mensagens não processadas carregam payload com dado pessoal.
    • Sistemas de terceiros — provedor de e-mail transacional, ferramenta de suporte, analytics de produto, gateway de pagamento, data warehouse.
    • Logs de aplicação — o lugar onde dado pessoal vaza por acidente com mais frequência.

    O mapeamento vira artefato versionado no repositório, não uma planilha. Um arquivo declarativo listando cada destino, a classe de dado que ele guarda e a ação esperada permite escrever um teste que falha quando alguém cria uma tabela nova com tenant_id e esquece de registrá-la.

    Uma consulta ao catálogo do Postgres devolve todas as tabelas com coluna de tenant e serve de base para o teste de cobertura do inventário:

    SELECT table_name FROM information_schema.columns WHERE column_name IN ('tenant_id','account_id') AND table_schema='public' ORDER BY table_name;

    Compare o resultado com o manifesto declarado e falhe o build na diferença.

    Soft delete, anonimização ou remoção física: o critério

    A escolha depende de duas perguntas: o dado identifica uma pessoa e existe obrigação de retenção sobre ele.

    Tipo de dado Ação Por quê
    Credenciais, tokens, chaves de API Remoção física imediata Nenhum valor de retenção, risco alto se vazarem
    Conteúdo produzido pelo tenant (documentos, registros de negócio) Remoção após janela de arrependimento Pertence ao cliente, sem valor para o provedor
    Registros fiscais e contratuais Preservar, anonimizar campos pessoais acessórios Retenção obrigatória por prazo legal
    Log de auditoria de segurança Preservar com identificador pseudonimizado Precisa manter rastreabilidade de eventos
    Métricas agregadas de uso Preservar se já não forem identificáveis Agregado sem chave de reidentificação deixa de ser dado pessoal
    Backups Expirar pelo ciclo normal de retenção Edição cirúrgica de backup quebra a integridade da cópia

    A janela de arrependimento merece atenção. Trinta dias entre a solicitação e a remoção definitiva reduz drasticamente o volume de restaurações de emergência, e é curta o bastante para caber num prazo de resposta razoável. O erro comum é implementar a janela e não implementar o job que executa a remoção quando ela termina — a conta fica em pending_deletion para sempre.

    Sobre soft delete: ele é uma ferramenta de reversibilidade operacional, não uma forma de exclusão. Se a coluna deleted_at é o único mecanismo, a exclusão não aconteceu. Vale usá-la como estado intermediário e ter um processo separado que consome esse estado.

    Backups, réplicas e logs: onde o dado sobrevive à exclusão

    Backup é a exceção legítima. Tentar apagar uma linha de dentro de um dump comprimido de dezenas de gigabytes é caro, arriscado e invalida a garantia de restauração. A posição defensável é declarar o prazo de retenção de backup, mantê-lo curto o suficiente para ser justificável, e registrar que dados excluídos persistem nas cópias até expirarem naturalmente — com o compromisso de reaplicar a exclusão caso alguma restauração traga o registro de volta.

    Esse compromisso precisa virar código. Um procedimento de restauração que não reexecuta a fila de exclusões pendentes é um procedimento que ressuscita contas removidas. Vale manter uma tabela de exclusões confirmadas fora do ciclo de backup principal, ou pelo menos um log append-only de identificadores excluídos que o pós-restore consulta.

    Réplicas de leitura seguem o primário e não exigem tratamento separado. Já um data warehouse alimentado por CDC ou por carga noturna é um sistema independente: precisa receber o evento de exclusão explicitamente. O mesmo vale para o índice de busca, que quase sempre é atualizado por evento e quase nunca tem o evento de exclusão testado.

    Logs de aplicação são o ponto cego. Se o pipeline registra corpo de requisição, headers ou payload de erro, há dado pessoal ali. A saída realista não é apagar log — é reduzir a retenção da camada que contém payload e mascarar campos sensíveis na origem, antes de escrever.

    Vale medir o custo antes de prometer prazo. Num SaaS com object storage volumoso, a remoção de arquivos de um tenant grande pode levar horas e competir por throughput com o tráfego de produção. Rodar isso de forma síncrona dentro de uma requisição HTTP não funciona; rodar sem limite de taxa degrada o serviço de todo mundo. O job precisa de controle de concorrência e de janela preferencial, e o prazo comunicado ao cliente precisa acomodar o pior caso, não o caso médio.

    Checklist de execução

    A sequência abaixo assume um SaaS multi-tenant com banco relacional, object storage, índice de busca e integrações de terceiros. Cada item é verificável.

    • Solicitação registrada com identificador do tenant, quem pediu, data e canal. Sem esse registro não há como provar prazo.
    • Autenticação reforçada do pedido — exclusão disparada por e-mail sem confirmação é vetor de ataque. Exija ação autenticada de um administrador do tenant ou confirmação por segundo canal.
    • Conta movida para estado de exclusão pendente, com acesso bloqueado, integrações desativadas e webhooks suspensos.
    • Cancelamento da assinatura no gateway, para não gerar cobrança sobre uma conta em remoção.
    • Job agendado para o fim da janela, com identificador idempotente. Reexecução do job não pode falhar nem duplicar efeitos.
    • Execução por manifesto: o job percorre o inventário declarado, não uma lista hardcoded. Cada destino devolve confirmação.
    • Propagação para terceiros com tratamento de falha explícito — provedor fora do ar não pode fazer o job inteiro abortar sem retomada.
    • Invalidação de cache e reindexação do índice de busca, com verificação de que a consulta pelo tenant devolve vazio.
    • Registro de conclusão em log append-only, fora do ciclo de backup padrão, com carimbo de tempo e lista de destinos confirmados.
    • Verificação automatizada que roda depois do job e consulta cada destino procurando resíduo. Falha aqui abre incidente.

    Dos dez itens, os dois que mais faltam em implementações reais são a idempotência do job e a verificação posterior. Sem idempotência, a primeira falha parcial deixa o sistema num estado que ninguém sabe reconciliar. Sem verificação, a equipe descobre o resíduo pela reclamação do cliente.

    Comece pelo manifesto e pelo teste de cobertura que compara o manifesto com o catálogo do banco. É o item mais barato de construir e o único que impede a regressão silenciosa quando o schema crescer.

  • Kubernetes ou runtime gerenciado de containers: onde o cluster próprio passa a compensar

    Um time que roda seis serviços em ECS Fargate ou Cloud Run chega, em algum momento, na mesma pergunta: vale a pena assumir um cluster Kubernetes? A resposta honesta não é “depende do tamanho”. É: depende de quantas vezes por trimestre o runtime gerenciado obriga a contornar uma limitação com gambiarra, e de quanto tempo de engenharia essa gambiarra consome.

    Este comparativo trata das duas opções como elas se apresentam hoje para times de 5 a 40 pessoas: de um lado, runtimes gerenciados de container (ECS/Fargate, Cloud Run, Azure Container Apps); do outro, Kubernetes — seja cluster gerenciado do provedor, seja auto-hospedado.

    A diferença real não é orquestração, é superfície de extensão

    Os dois modelos resolvem o básico: sobem containers, fazem rolling deploy, escalam por métrica, reiniciam o que morreu. Quem compara pelo básico conclui que Kubernetes é complexidade desnecessária — e está certo, enquanto o básico for suficiente.

    A diferença aparece quando o requisito sai do básico. Kubernetes expõe um modelo de recursos extensível: Custom Resource Definitions, controladores, admission webhooks, operadores. Isso significa que padrões operacionais viram objeto declarativo versionado no repositório. Um operador de Postgres cuida de failover e backup; um controlador de certificados renova TLS; uma policy engine bloqueia deploy de container rodando como root — tudo pela mesma API.

    Vale um exemplo concreto de como isso se materializa. Impedir que qualquer container suba com o filesystem raiz gravável é, em Kubernetes, um objeto declarativo aplicado ao namespace inteiro — quem tentar violar recebe erro no kubectl apply, não um comentário em code review. Fora do cluster, a mesma regra vira um passo de pipeline que alguém pode pular com skip ci num deploy de emergência às onze da noite, que é exatamente quando a regra importa.

    Runtime gerenciado não tem esse ponto de extensão. Se o comportamento desejado não existe como flag do serviço, ele vira script de CI, função serverless auxiliar ou processo manual documentado no Notion. Cada contorno desses é barato isoladamente e caro no agregado.

    O teste prático: liste os últimos dez incidentes ou pedidos de infraestrutura do time e marque quantos foram resolvidos com uma flag do serviço gerenciado e quantos exigiram código auxiliar. Se mais de três exigiram código auxiliar, a superfície de extensão já está fazendo falta.

    O custo da fatura e o custo do calendário

    A comparação de preço costuma ser feita errado porque compara apenas computação. Em runtime gerenciado, paga-se por vCPU e memória alocados por segundo, com sobretaxa embutida pela abstração — a ordem de grandeza usual é um adicional relevante sobre o preço de máquina equivalente, e a proporção varia bastante entre provedores e regiões. Em Kubernetes gerenciado, paga-se pelas instâncias do node pool, mais uma taxa fixa de control plane por cluster.

    Três efeitos mudam a conta quando a carga cresce:

    • Bin packing. Kubernetes empacota vários pods pequenos numa mesma instância. Runtime gerenciado cobra por task, e task pequena com pico raro paga alocação ociosa o tempo todo.
    • Instâncias spot. Node pools spot com PodDisruptionBudget e tolerations bem configurados cortam boa parte do custo de computação de cargas tolerantes a interrupção. Runtimes gerenciados oferecem equivalentes mais limitados.
    • Escala a zero. Aqui o gerenciado ganha. Cloud Run e Container Apps zeram serviço sem tráfego; num cluster, manter o node pool de pé para um serviço ocioso custa mais do que o serviço vale.

    Um exercício útil antes de decidir: pegue os serviços atuais, some o CPU e a memória efetivamente usados no percentil 95 e compare com o que está alocado. Em ambientes com muitos serviços pequenos, é comum que a alocação seja várias vezes o uso — a proporção varia demais entre workloads para citar um número, mas ela é mensurável em qualquer painel de métricas e não deveria ser estimada de cabeça. Essa diferença é exatamente o que o bin packing recupera, e é o único ganho de custo do cluster que aparece na fatura sem depender de renegociação com o provedor.

    O custo de calendário é o que decide de verdade. Um cluster exige alguém responsável por upgrade de versão a cada poucos meses, por atualização de CRDs e controladores, por gestão de nodes e por dívida de configuração. Não é um projeto — é uma rotina permanente. Times que adotaram cluster sem designar essa responsabilidade acabam com versão desatualizada, controlador quebrado no upgrade seguinte e uma manhã perdida por incidente.

    Onde cada modelo trava

    Requisito Runtime gerenciado Kubernetes
    Deploy simples com rollback Nativo, sem configuração Nativo, mas exige manifesto e pipeline
    Escala a zero Nativo em Cloud Run/Container Apps Só com KEDA ou Knative por cima
    Job batch com dependência entre etapas Depende de orquestrador externo Job, CronJob e operadores de workflow
    Workload com estado (banco, fila, cache) Normalmente inviável — usa serviço gerenciado StatefulSet e operador, com custo operacional real
    Rede: mTLS entre serviços, roteamento por header Limitado ao que o serviço expõe Service mesh ou Gateway API
    Política de segurança obrigatória no deploy Verificação no CI, contornável Admission controller, bloqueia no cluster
    Portabilidade entre provedores Reescrita da camada de deploy Manifests majoritariamente reaproveitáveis
    Tempo até o primeiro serviço em produção Horas Dias a semanas, contando pipeline e observabilidade

    A linha mais importante da tabela é a última. Runtime gerenciado entrega valor no primeiro dia; cluster cobra adiantado e devolve depois. Adotar Kubernetes antes de ter os requisitos das linhas do meio é pagar adiantado por algo que talvez nunca seja usado.

    Há ainda um custo que a tabela não captura: contratação e rotatividade. Manifesto Kubernetes é conhecimento transferível — quem opera cluster num lugar opera em outro. Configuração de runtime gerenciado é conhecimento de provedor, e o time que trocar de nuvem reescreve a camada de deploy inteira. Para produto que roda em ambiente do cliente, ou para empresa com cláusula de portabilidade em contrato, isso deixa de ser preferência técnica e vira requisito comercial.

    Os quatro sinais de que o cluster passou a compensar

    Não existe número de serviços que funcione como gatilho. Existem sintomas:

    1. Workload com estado entrou no roadmap. Um ClickHouse, um Kafka, um Elasticsearch que o time quer operar em vez de contratar. Runtime gerenciado não tem resposta razoável para isso.
    2. Requisito de rede entre serviços. mTLS obrigatório, roteamento canário por header, política de saída restrita por serviço. São coisas que um mesh resolve declarativamente e que, fora do cluster, viram configuração de load balancer espalhada.
    3. Multi-ambiente com paridade exigida. Quando dev, staging e produção precisam ser o mesmo objeto com valores diferentes, e não três configurações mantidas à mão.
    4. Conta de computação em que o desperdício por alocação já é visível. Quando o time consegue apontar que boa parte do que se paga é CPU alocada e não usada, o bin packing começa a pagar o esforço.

    Um sinal isolado não justifica a migração. Dois sinais simultâneos, com pessoa designada para operar o cluster, justificam. Nenhum sinal e a resposta é ficar onde está — o custo de manter um cluster subutilizado é pago em atenção de engenharia, que é o recurso mais escasso do time.

    Migrar sem parar o produto

    A migração que funciona não é big bang. É mover um serviço de baixo risco, estabilizar a plataforma em cima dele e só então trazer o resto. Na prática:

    O primeiro serviço a migrar deve ser stateless, ter tráfego real (para exercitar escala e observabilidade) e não estar no caminho crítico de receita. Um worker de processamento assíncrono costuma servir bem: se ele atrasar cinco minutos durante o ajuste, ninguém perde venda.

    Antes de migrar o segundo serviço, o cluster precisa ter três coisas prontas: coleta de métricas e logs equivalente à que já existia, política de recursos definida por namespace e um caminho de rollback testado — não descrito, testado.

    Vale fixar limites desde o primeiro deploy, porque adicionar depois exige rediscutir capacidade com o time todo:

    resources:
      requests:
        cpu: 100m
        memory: 128Mi
      limits:
        memory: 512Mi

    Dois erros aparecem com frequência nessa fase. O primeiro é copiar os valores de CPU e memória da configuração antiga sem revisar: alocação herdada de runtime gerenciado costuma estar superdimensionada, e trazê-la para o cluster anula o ganho de empacotamento que motivou a migração. O segundo é rodar os dois ambientes em paralelo por tempo indefinido “só por garantia” — o custo dobra, o time mantém duas pipelines e a decisão de desligar o ambiente antigo vai sendo adiada. Defina a data de desligamento junto com a data de corte de tráfego.

    A escolha de definir request de CPU sem limit de CPU é deliberada: limite de CPU gera throttling em picos curtos e degrada latência sem economizar nada relevante. Memória é diferente — sem limite, um vazamento derruba o node inteiro em vez de derrubar só o pod.

    O passo seguinte, para quem está avaliando agora: pegue a fatura de computação dos últimos três meses, separe quanto é alocação e quanto é uso efetivo, e coloque ao lado a lista de contornos que o time escreveu para driblar limitação do runtime atual. Se o desperdício de alocação sozinho não paga um dia de engenharia por semana, o cluster ainda não compensa — e essa conta deve ser refeita a cada trimestre, não a cada reunião de arquitetura.

  • Rate limiting em API multi-tenant: por que limitar por IP não protege o que importa

    Uma API multi-tenant que limita requisições por endereço IP está medindo a coisa errada. O IP identifica de onde a chamada veio, não quem vai pagar por ela. Num cliente que roda atrás de um NAT corporativo, cem usuários compartilham um IP e batem no teto de um só. Num cliente que roda em funções serverless, cada invocação sai de um IP diferente e o teto nunca é alcançado. Os dois casos aparecem no mesmo dia, no mesmo produto, e o resultado é o pior possível: reclamação de quem paga e consumo livre de quem abusa.

    O problema não é o algoritmo de contagem. É a chave. Antes de discutir janela deslizante contra token bucket, vale decidir o que está sendo contado e por quê.

    A chave de rate limit deve espelhar a unidade de cobrança

    Em produto SaaS, a regra prática é curta: o limite existe para proteger a plataforma de um tenant e para dar previsibilidade ao tenant. Se o contrato vende 100 mil chamadas por mês para a conta, o contador precisa viver na conta — não no IP, não no token individual, não na sessão.

    Isso não significa uma chave só. Um esquema que funciona bem na prática usa três níveis simultâneos, avaliados em ordem, do mais barato ao mais caro de calcular:

    • Global por tenant: o teto contratual. Protege a plataforma e é o número que o comercial promete.
    • Por credencial dentro do tenant: impede que uma integração mal escrita do cliente consuma sozinha a cota inteira e derrube as outras integrações do mesmo cliente.
    • Por rota crítica: endpoints caros — relatórios, exportações, buscas amplas — merecem contador próprio, porque uma chamada dessas custa cem vezes uma leitura simples.

    O IP entra num quarto nível, e só para tráfego não autenticado: rotas de login, de recuperação de senha, de cadastro. Ali o IP é o único identificador disponível e o objetivo é outro — conter enumeração e força bruta, não distribuir cota.

    A chave composta que resolve a maioria dos casos é tenant_id:credential_id:route_class. Os três componentes já existem no contexto da requisição depois da autenticação; nenhum exige consulta extra ao banco.

    Nem toda requisição custa o mesmo, e o contador precisa saber disso

    Contar requisições trata um GET /health e um POST /reports/export como equivalentes. Não são. O segundo pode ocupar um worker por trinta segundos e varrer milhões de linhas.

    A alternativa é contar custo em vez de chamadas: cada rota declara um peso, e o consumo debitado é o peso, não a unidade. Uma leitura simples custa 1, uma busca paginada custa 5, uma exportação custa 50. O cliente continua vendo um número único de saldo, e a plataforma passa a se proteger do que realmente dói.

    O custo dessa abordagem é de comunicação, não de implementação. Cota em “unidades” exige documentar a tabela de pesos e mantê-la estável — mudar o peso de uma rota é, na prática, mudar o preço. Vale a pena quando existem endpoints com ordens de grandeza de diferença entre si. Não vale quando todas as rotas são leituras e escritas parecidas: nesse caso, contar requisição é mais simples e igualmente honesto.

    Onde o contador vive muda o que ele consegue prometer

    Há três lugares plausíveis para guardar o estado do limite, e a escolha determina a precisão possível.

    Local do contador Precisão Latência somada Falha quando
    Memória do processo Baixa: cada réplica conta separado Desprezível Escala horizontal — N réplicas viram N vezes o limite
    Redis compartilhado Alta Um round-trip, tipicamente sub-milissegundo na mesma rede Redis fica indisponível ou vira ponto único
    API gateway gerenciado Alta, mas com a granularidade que o gateway oferece Nenhuma adicional A chave desejada não cabe no modelo do gateway

    O contador em memória local costuma ser descartado cedo demais. Ele tem um uso legítimo: como primeira barreira, com um teto propositalmente frouxo, para absorver picos absurdos sem sequer tocar o Redis. Um limite local de, digamos, dez vezes o teto real por réplica corta um cliente em laço infinito antes que ele gere carga na infraestrutura de contagem. A contagem exata continua no Redis, uma camada depois.

    Sobre o Redis: ele vira dependência crítica do caminho de request. Isso precisa de decisão explícita sobre o que fazer quando ele não responde. Falhar aberto — deixar passar — mantém o produto no ar e abre mão da proteção justamente no momento de instabilidade. Falhar fechado — bloquear tudo — transforma indisponibilidade do Redis em indisponibilidade da API.

    A escolha defensável para a maioria dos produtos é falhar aberto no limite por tenant e falhar fechado nas rotas não autenticadas. O tenant pagante não deve ser punido por um problema de infraestrutura; a rota de login não deve ficar desprotegida sob nenhuma circunstância.

    Escolha do algoritmo: o único ponto onde janela fixa realmente atrapalha

    Janela fixa conta requisições em blocos de tempo alinhados ao relógio — de 10:00:00 a 10:00:59, depois zera. É trivial de implementar com INCR e EXPIRE, e tem um defeito conhecido: um cliente pode gastar a cota inteira nos últimos segundos de uma janela e a cota inteira nos primeiros da seguinte, produzindo o dobro do teto em um intervalo curto.

    Se o limite existe para faturamento, esse defeito é irrelevante — no fim do mês o total está certo. Se o limite existe para proteger um recurso com capacidade instantânea limitada, o dobro do teto é exatamente o que se queria evitar.

    Token bucket resolve o pico e ainda expressa uma intenção que os outros algoritmos não expressam: taxa média sustentada com rajada tolerada. Um balde de 100 tokens com reposição de 10 por segundo permite que o cliente acumule folga durante um período ocioso e gaste tudo de uma vez — comportamento típico de integração que roda em lote de madrugada, e que faz sentido permitir.

    A recomendação prática: token bucket para o limite por tenant e por credencial, onde rajada é legítima; janela deslizante para rotas caras, onde rajada é o próprio problema; janela fixa apenas em contadores de billing, onde a simplicidade compensa.

    A resposta do 429 é parte da API, não um detalhe de erro

    Um limite estourado devolve 429 Too Many Requests. O que acompanha esse status determina se o cliente consegue se comportar bem ou se vai entrar em laço de retentativa.

    Três cabeçalhos resolvem quase tudo. RateLimit-Limit informa o teto da janela. RateLimit-Remaining informa o saldo. RateLimit-Reset informa em quantos segundos o saldo volta. Em respostas de bloqueio, Retry-After é o campo que bibliotecas de cliente já sabem interpretar sem código adicional.

    Devolver esses cabeçalhos em todas as respostas — não só nas bloqueadas — permite que o cliente desacelere antes de bater no teto. É a diferença entre uma integração que se ajusta sozinha e uma que descobre o limite por colisão.

    Falta o corpo. Um 429 com corpo vazio obriga o desenvolvedor do outro lado a adivinhar qual dos limites foi atingido. Um corpo que diga qual chave estourou, qual era o teto e quando ele volta economiza uma abertura de chamado por cliente novo:

    {
      "error": "rate_limit_exceeded",
      "scope": "tenant",
      "limit": 1000,
      "window_seconds": 60,
      "retry_after_seconds": 23
    }

    O campo scope é o que mais poupa tempo de suporte: com três níveis de limite ativos, saber que o estouro foi na credencial e não no tenant muda completamente a ação do cliente — trocar de integração em vez de comprar mais cota.

    Antes de subir: o que costuma faltar

    Rate limiting entra em produção com três lacunas recorrentes. A primeira é ausência de observabilidade: sem métrica de quantas requisições foram bloqueadas por tenant e por escopo, não há como distinguir um limite bem calibrado de um limite que está silenciosamente quebrando a integração de um cliente grande.

    A segunda é ausência de modo de observação. Ligar limites novos direto em bloqueio é apostar que os números foram bem escolhidos. Rodar por duas semanas apenas contando e emitindo métrica, sem bloquear, mostra o percentil real de consumo e evita descobrir o erro pelo canal de suporte.

    A terceira é ausência de válvula de escape. Vai existir o cliente com contrato especial, a migração legítima que precisa de dez vezes a cota por três dias, o teste de carga combinado. Se o único jeito de liberar isso for deploy, alguém vai desligar o rate limiting inteiro numa madrugada. Um override por tenant com validade — armazenado junto com os limites, consultado no mesmo lugar — resolve o caso e mantém o rastro de quem liberou o quê e até quando.

    O próximo passo concreto: levantar, no tráfego atual, o percentil 99 de requisições por minuto por tenant e por rota. Sem esse número, qualquer limite escolhido é chute — e chute em rate limiting sempre erra para o lado de quem paga mais.