Blog

  • 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.

  • Decisão de arquitetura — Busca no Postgres ou Elasticsearch: até onde o full-text nativo aguenta

    Um SaaS com alguns milhões de registros ligou a busca textual em cima do Postgres e ela funcionou por dois anos. Agora a consulta que respondia em 40 ms leva 900 ms quando o filtro por tenant entra junto, o time de produto pediu filtros facetados na lateral e o suporte reclama que buscar “nota fical” não encontra “nota fiscal”. A pergunta que chega até a arquitetura é sempre a mesma: sobe um Elasticsearch ou dá para espremer mais o banco?

    A resposta não é a mesma para os dois casos que costumam ser confundidos. Buscar texto e construir um motor de relevância são problemas diferentes, e o Postgres resolve o primeiro muito melhor do que a maioria dos times imagina.

    O que o Postgres entrega de fábrica

    O full-text search nativo do Postgres é maduro e cobre bem mais do que LIKE '%termo%'. Ele converte texto em tsvector — a lista de lexemas normalizados com posição — e compara contra um tsquery. Com stemming em português, remoção de stopwords e um índice GIN por cima, uma busca em uma tabela de poucos milhões de linhas responde em dezenas de milissegundos.

    A forma correta de montar isso em um schema novo é com coluna gerada, não com trigger:

    ALTER TABLE documentos
      ADD COLUMN busca tsvector
      GENERATED ALWAYS AS (
        setweight(to_tsvector('portuguese', coalesce(titulo, '')), 'A') ||
        setweight(to_tsvector('portuguese', coalesce(corpo,  '')), 'B')
      ) STORED;
    
    CREATE INDEX documentos_busca_idx ON documentos USING GIN (busca);

    O setweight é o detalhe que separa uma busca aceitável de uma ruim: ele permite que uma ocorrência no título pese mais do que no corpo quando o ts_rank ordena. Do lado da consulta, websearch_to_tsquery aceita a sintaxe que o usuário já conhece de buscador — aspas para expressão exata, - para exclusão, or — sem que a aplicação precise fazer parsing próprio.

    Para tolerância a erro de digitação, a extensão pg_trgm resolve o caso de “nota fical” com similaridade por trigramas, e unaccent resolve o de acentuação inconsistente. As duas cobrem a maior parte das reclamações reais de usuário sem nenhuma infraestrutura nova.

    Antes de decidir qualquer coisa, rode EXPLAIN (ANALYZE, BUFFERS) na consulta lenta com o filtro de tenant incluído. Na maioria dos casos o gargalo não é o GIN — é o planner escolhendo varrer a tabela porque a seletividade do filtro combinado está mal estimada, o que se corrige com índice composto ou CREATE STATISTICS.

    Onde o full-text nativo começa a doer

    Os limites aparecem em quatro frentes, e vale identificar qual delas está travando antes de trocar de ferramenta.

    Relevância. O ts_rank pontua por frequência e posição do termo, sem normalizar por tamanho de documento da forma que o BM25 faz. Em corpus com documentos de tamanhos muito diferentes, textos longos sobem indevidamente. Dá para compensar com ts_rank usando os parâmetros de normalização, mas é ajuste manual e frágil.

    Escrita. Índice GIN tem custo de atualização alto. Em tabela com escrita intensa, a fila de pendências do GIN cresce e as consultas passam a pagar por ela. O parâmetro fastupdate e o dimensionamento de gin_pending_list_limit ajudam, mas em cargas de escrita constante o índice vira o ponto quente da tabela.

    Facetas. Contar quantos resultados existem por categoria, por autor e por período, sobre o mesmo conjunto filtrado, é agregação sobre o resultado inteiro — não sobre a página. No Postgres isso significa varrer o conjunto completo a cada consulta, e o custo cresce linearmente com o número de acertos. É aqui que a maioria dos times bate no muro.

    Highlight. O ts_headline não usa o índice: ele reprocessa o texto original de cada linha retornada. Em página de 20 resultados com documentos grandes, ele sozinho pode dominar o tempo da consulta.

    Há um quinto ponto que não é limitação do full-text, mas é onde o problema aparece primeiro em SaaS: a combinação de índice GIN com filtro por tenant. São dois índices independentes, e o planner precisa decidir se parte da busca textual e filtra depois, ou se parte do tenant e varre. Quando o tenant é grande, a primeira estratégia ganha; quando é pequeno, a segunda. Estimativa errada nesse ponto explica a maior parte dos casos de “a busca ficou lenta de repente para um cliente só”.

    O que o Elasticsearch resolve e o que ele cobra

    O Elasticsearch — e o OpenSearch, que para efeito desta decisão se comporta igual — entrega BM25 por padrão, agregações que foram desenhadas justamente para facetas, highlight que usa a estrutura invertida em vez de reprocessar o documento, e uma cadeia de analisadores em que sinônimos, tolerância a erro e normalização são configuração, não gambiarra de query.

    A conta que raramente entra na proposta é a do outro lado. Um cluster separado significa JVM para dimensionar, shards para planejar, snapshot para operar e uma segunda fonte de verdade para manter sincronizada. Essa sincronização é o custo real: dual-write na aplicação cria janela de inconsistência a cada falha parcial, e captura de mudanças via replicação lógica exige um consumidor a mais em produção. Além disso, mudança de mapping em campo já indexado costuma exigir reindexação completa com alias — procedimento que precisa estar ensaiado antes de ser necessário.

    Há ainda a consistência. O índice fica visível para busca depois do refresh, que por padrão é próximo de um segundo. Fluxo em que o usuário salva um registro e imediatamente busca por ele volta vazio, e a correção — forçar refresh na escrita — anula boa parte do ganho de throughput.

    O critério de escolha

    Fator Postgres full-text Elasticsearch / OpenSearch
    Relevância ts_rank com pesos manuais BM25 por padrão, ajustável por campo
    Facetas sobre resultado grande Agregação linear no conjunto Agregações nativas, custo previsível
    Sinônimos e erro de digitação pg_trgm e unaccent, combinam mal com ranking Cadeia de analisadores, configuração declarativa
    Consistência com o dado transacional Imediata, mesma transação Eventual, janela de refresh
    Custo operacional Zero adicional Cluster, sincronização e plano de reindexação
    Isolamento por tenant Filtro na cláusula WHERE Filtro, roteamento ou índice por tenant

    O critério que funciona na prática é de contagem, não de tamanho de corpus: o Elasticsearch se paga quando pelo menos dois dos três requisitos abaixo são de produto, e não de preferência técnica — facetas contadas sobre o resultado inteiro, relevância como diferencial competitivo com ajuste contínuo, e busca sobre volume que já não cabe confortavelmente em cache no nó do banco.

    Com um só desses requisitos, o Postgres ainda ganha. Um SaaS B2B típico, com milhões de registros mas centenas de resultados por tenant depois do filtro, quase nunca chega aos dois. Nesse caso o esforço rende muito mais aplicado a índice composto, particionamento por tenant e limite de resultado do que a um cluster novo.

    A exceção clara é busca como produto — catálogo público, marketplace, base de conhecimento com tráfego de busca orgânica. Aí a relevância é o produto, o ajuste é semanal e o Postgres vira gargalo de roadmap antes de virar gargalo de performance.

    Se a migração for o caminho, o que fazer primeiro

    O erro comum é começar pela infraestrutura. A ordem que reduz risco é outra: primeiro instrumentar a busca atual — distribuição de latência por percentil, taxa de consulta sem resultado, taxa de clique no primeiro resultado. Sem esses três números não existe forma de saber se o cluster novo melhorou alguma coisa.

    Depois, indexar em paralelo e servir do Postgres. A aplicação escreve nos dois, consulta apenas o banco, e uma tarefa de comparação registra divergência de resultado entre as duas fontes. Semanas nesse modo revelam os problemas de mapping e de sincronização enquanto ninguém depende do índice novo.

    Só então trocar a leitura, e de forma progressiva por tenant, mantendo o caminho antigo alcançável por configuração. Rollback de busca precisa ser mudança de flag, não deploy.

    Duas decisões de modelagem valem ser tomadas antes da primeira indexação, porque mudá-las depois custa reindexação: o que vai no índice e o que fica só no banco, e como o tenant é isolado. Indexar o documento inteiro parece conveniente, mas infla o cluster com campo que ninguém consulta — o índice deve carregar o que participa da busca, do filtro ou da exibição do resultado, e o identificador para buscar o resto no banco. Quanto ao tenant, índice por cliente só se sustenta enquanto o número de clientes for baixo; acima disso o custo de shard por índice inviabiliza, e o caminho é índice compartilhado com roteamento pelo identificador do tenant.

  • Post-mortem: o cache que expirou junto e derrubou o banco por 12 minutos

    Cenário: catálogo de produtos servido por uma API em Node, cache em Redis com TTL fixo de 300 segundos, algo em torno de 4 mil requisições por segundo no pico da tarde. Um deploy às 14h02 subiu pods novos e, junto, um script de invalidação limpou o namespace inteiro do cache. Doze minutos depois o banco primário estava com o pool de conexões saturado, o p99 do checkout passava de 20 segundos e o balanceador devolvia 502.

    O relato abaixo é reconstruído para ilustrar o modo de falha — não descreve o incidente de nenhuma empresa específica. O padrão, porém, é dos mais frequentes em qualquer stack que coloque cache com TTL uniforme na frente de um banco relacional: o problema não foi o cache ter esvaziado, foi todas as chaves terem voltado a valer no mesmo instante.

    A curva diz mais que a linha do tempo

    Na leitura do gráfico, o que chama atenção não é o pico de latência — é o formato dele. Requisições ao banco saltam de uma base estável para dez, quinze vezes o volume normal em menos de um segundo, permanecem no teto por alguns minutos e caem de novo de forma abrupta. Não é a curva de um crescimento de tráfego: tráfego de usuário não dobra em 200 milissegundos. É a assinatura de um evento sincronizado do lado da aplicação.

    Três indícios separam esse cenário de uma sobrecarga genuína de tráfego. Primeiro, o volume de requisições HTTP na borda continua estável enquanto as queries ao banco explodem — a razão query por request muda, e é isso que importa. Segundo, as queries que dominam o topo são idênticas entre si, variando só no parâmetro de ID. Terceiro, o pico se repete em intervalos que correspondem ao TTL configurado, mais amortecido a cada ciclo, até sumir. Esse terceiro sinal é o mais conclusivo: o sistema está oscilando na frequência do próprio cache.

    Expiração sincronizada e o rebanho que atropela

    O mecanismo é simples. Quando um lote grande de chaves é gravado no mesmo instante — depois de um flush, de um deploy que muda o prefixo das chaves, ou de um cold start de um cluster novo — todas recebem o mesmo TTL. Trezentos segundos depois, todas expiram juntas. Cada requisição que chega nessa janela encontra um miss, e cada uma dispara sua própria recomputação contra o banco.

    O agravante é que a recomputação não é instantânea. Se a query que popula a chave leva 180 milissegundos, e chegam 4 mil requisições por segundo, aproximadamente 720 requisições entram na janela em que a primeira ainda não terminou de gravar o resultado. Nenhuma delas encontra o cache preenchido. Todas vão ao banco. O banco fica mais lento sob essa carga, a query passa a levar 900 milissegundos, e agora são 3.600 requisições concorrentes fazendo o mesmo trabalho. A degradação se realimenta — é por isso que o gráfico sobe em parede, e não em rampa.

    Há dois nomes circulando para variações do mesmo problema, e vale separá-los porque a correção é diferente. Cache stampede descreve muitas chaves distintas expirando ao mesmo tempo. Thundering herd em chave quente descreve uma única chave muito requisitada expirando e sendo recomputada por N clientes simultâneos. O primeiro é um problema de distribuição de TTL; o segundo, de exclusão mútua na recomputação.

    Jitter no TTL resolve a maioria dos casos

    A correção para o primeiro caso cabe em uma linha: em vez de gravar todas as chaves com o mesmo TTL, sorteie um desvio dentro de uma faixa. Um TTL base de 300 segundos com jitter de 20% distribui as expirações ao longo de uma janela de 120 segundos em vez de concentrá-las num instante.

    const TTL_BASE = 300;
    const JITTER = 0.2;
    
    function ttlComJitter() {
      const desvio = TTL_BASE * JITTER;
      return Math.round(TTL_BASE - desvio + Math.random() * desvio * 2);
    }
    
    await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
    

    A escolha da faixa é um trade-off entre suavidade e frescor. Jitter de 10% mal resolve quando o volume é alto — a janela ainda é estreita demais. Jitter de 50% distribui bem, mas significa que parte dos dados pode viver metade do tempo pretendido, o que encarece o custo de banco no regime normal. Para cache de leitura de catálogo, 20% a 30% costuma ser o ponto razoável. Para dados em que a idade máxima é um requisito de negócio — saldo, estoque reservado, limite de crédito —, jitter só para baixo: sorteie entre 70% e 100% do TTL nominal, nunca acima.

    Se o pipeline de deploy invalida cache por prefixo, o jitter não ajuda no primeiro repovoamento — todas as chaves nascem juntas de qualquer forma. Nesse caso o ganho vem de aquecer o cache antes de trocar o tráfego, ou simplesmente de parar de invalidar o namespace inteiro a cada deploy.

    A chave quente exige lock, não jitter

    Jitter distribui expirações entre chaves diferentes. Não faz nada por uma chave só, requisitada mil vezes por segundo, cujo recálculo custa caro. Aqui a ferramenta é exclusão mútua: o primeiro cliente que encontra o miss adquire um lock curto e recomputa; os demais aguardam ou servem o valor anterior.

    async function buscarComLock(chave, recomputar) {
      const cache = await redis.get(chave);
      if (cache) return JSON.parse(cache);
    
      const lock = await redis.set(`lock:${chave}`, '1', 'NX', 'PX', 5000);
      if (!lock) {
        await sleep(50);
        return buscarComLock(chave, recomputar);
      }
    
      try {
        const valor = await recomputar();
        await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
        return valor;
      } finally {
        await redis.del(`lock:${chave}`);
      }
    }

    Três detalhes decidem se essa implementação ajuda ou piora. O lock precisa de TTL próprio — sem o PX, um processo que morre no meio da recomputação trava a chave para sempre. O retry precisa de teto: espera indefinida transforma contenção de cache em esgotamento do pool de conexões HTTP. E o finally só libera o lock com segurança se o valor gravado for verificado antes da remoção; em cluster com failover, a versão correta usa um token aleatório e um script Lua para o delete condicional.

    Servir dado velho de propósito

    A alternativa que evita o problema em vez de administrá-lo é nunca deixar a chave ficar vazia. Grava-se o valor com dois prazos: um lógico, curto, que marca quando o dado passa a ser considerado velho; e o TTL físico do Redis, bem mais longo. Quando uma leitura encontra o dado velho mas ainda presente, devolve ele imediatamente e dispara a recomputação em background.

    Estratégia Resolve Custo Quando escolher
    Jitter no TTL Expiração em lote de muitas chaves Nenhum, uma linha Sempre. É o piso, não a solução completa
    Lock de recomputação Chave quente com recálculo caro Latência extra para quem espera; complexidade de lock distribuído Poucas chaves muito requisitadas, recálculo acima de 200 ms
    Stale-while-revalidate Ambos, sem espera para o usuário Serve dado desatualizado por alguns segundos Leitura tolerante a atraso: catálogo, listagem, agregados
    Refresh proativo por job Ambos, com frescor previsível Recomputa mesmo o que ninguém pediu Conjunto pequeno e conhecido de chaves críticas

    Entre as quatro, a escolha padrão para leitura de catálogo é stale-while-revalidate somado a jitter. O argumento é direto: o usuário nunca paga a latência da recomputação, o banco recebe uma requisição por chave por ciclo em vez de centenas, e o preço — alguns segundos de dado desatualizado numa listagem de produtos — é irrelevante para o negócio. Lock só entra em cima disso, e apenas nas chaves em que a recomputação é pesada o suficiente para que várias em paralelo doam. Refresh proativo por job é o mais previsível, mas só se paga quando o conjunto de chaves críticas é pequeno e estável; em catálogo com cauda longa, recomputar o que ninguém pede é desperdício puro.

    O sinal que aparece antes da queda

    Nenhuma das correções acima adianta se o time só descobre o problema pelo alerta de 502. A métrica que antecipa o evento é a razão entre queries ao banco e requisições HTTP recebidas, medida em janela curta. No regime saudável ela é baixa e estável. Quando um lote de chaves expira junto, ela salta antes de a latência subir o suficiente para disparar qualquer alerta de disponibilidade — há uma janela de dezenas de segundos ali.

    Duas outras séries valem o painel. A taxa de miss do cache segmentada por prefixo de chave mostra qual conjunto está expirando em bloco, o que aponta direto para o trecho de código que grava com TTL fixo. E o tempo de espera no pool de conexões do banco, que é o recurso que efetivamente esgota — a saturação aparece nele bem antes de aparecer na CPU do banco, e times que monitoram só CPU descobrem tarde.

    Próximo passo concreto: procure no código toda chamada de escrita no cache que use uma constante de TTL e verifique quantas delas gravam em lote. Se houver mais de uma dezena de chaves nascendo no mesmo laço com o mesmo prazo, o jitter compensa antes de qualquer discussão sobre lock ou revalidação em background.

  • Checklist de implementação: SSO via SAML num SaaS que já tem login próprio

    O contrato enterprise trava na cláusula de autenticação: o cliente quer que os funcionários entrem pelo provedor de identidade corporativo e o produto só sabe validar e-mail e senha na própria tabela de usuários. A tentação é resolver isso como um recurso de tela de login. Não é. SSO via SAML mexe no modelo de dados, no ciclo de vida da conta, na política de sessão e no offboarding — e a maior parte dos incidentes pós-lançamento vem de decisões tomadas antes de qualquer XML ser assinado.

    O roteiro abaixo cobre a implementação em um SaaS multi-tenant que já roda em produção com autenticação própria. A ordem importa: cada bloco assume o anterior resolvido.

    O modelo de dados precisa mudar antes do primeiro XML

    Autenticação local costuma guardar identidade e credencial na mesma linha. O e-mail identifica o usuário, o hash da senha o autentica, e o sistema trata os dois como a mesma coisa. SAML quebra essa fusão: quem autentica é o provedor de identidade, quem identifica é um atributo que o provedor envia — e esse atributo pode não ser o e-mail.

    Três estruturas precisam existir antes de qualquer coisa:

    • Conexão de identidade por organização, não por usuário. Cada tenant tem no máximo uma conexão ativa, contendo entity ID do provedor, URL de SSO, certificado público de assinatura e o mapa de atributos.
    • Identificador externo estável no usuário, separado do e-mail. O NameID persistente ou o objectGUID do diretório sobrevive a troca de sobrenome, mudança de domínio e reorganização interna. O e-mail não sobrevive a nenhuma das três.
    • Método de autenticação por usuário, com valores como senha, saml ou ambos. Sem esse campo não há como fazer a migração gradual descrita adiante.

    Guardar o certificado do provedor com data de expiração explícita, e não só o PEM cru, poupa o incidente mais previsível dessa integração: o certificado do cliente vence, todo mundo da conta perde acesso ao mesmo tempo e o suporte descobre pelo chamado. Um job diário que compara a data de expiração com a data atual e dispara alerta com trinta e sete dias de antecedência custa uma tarde e evita a ligação de sábado.

    Descoberta de provedor: o passo que degrada a experiência quando é feito errado

    Com login local, o usuário digita e-mail e senha na mesma tela. Com SSO, o sistema precisa descobrir para qual provedor redirecionar antes de pedir credencial nenhuma — e essa descoberta acontece com a única informação disponível: o e-mail digitado.

    O padrão que funciona é o formulário em dois passos. A primeira tela pede apenas o e-mail. O backend extrai o domínio, consulta a tabela de domínios verificados por organização e decide: se há conexão SAML ativa, redireciona para o provedor; se não há, mostra o campo de senha. O usuário de conta pessoal continua com a experiência de sempre; o usuário corporativo nunca vê um campo de senha que não usa mais.

    Verificar o domínio é obrigatório e não é burocracia. Sem prova de posse — registro TXT no DNS ou arquivo servido em caminho conhecido — qualquer conta pode reivindicar um domínio alheio e sequestrar o fluxo de login de outra empresa. A verificação precisa ser revalidada periodicamente, porque domínio expira e muda de dono.

    Sobre o fluxo iniciado pelo provedor, aquele em que o usuário clica no ícone do aplicativo dentro do portal corporativo: ele é o que os clientes pedem e o que a equipe de segurança deveria resistir a entregar primeiro. Sem RelayState validado e sem correlação com uma requisição de origem, a aplicação recebe uma asserção não solicitada e precisa confiar nela sem contexto. Se for entregue, que seja depois do fluxo iniciado pelo provedor de serviço estar estável, com lista estrita de destinos permitidos no RelayState.

    Validar a asserção não é só conferir a assinatura. Antes de criar sessão, checar em sequência: assinatura contra o certificado daquela organização específica; Destination igual à URL de callback real; Audience igual ao entity ID do produto; NotBefore e NotOnOrAfter dentro da janela com tolerância de relógio de poucos minutos; InResponseTo correspondendo a uma requisição registrada; e o ID da asserção ainda não consumido, guardado em cache de uso único até o fim da validade. Pular a última checagem transforma uma asserção capturada em replay válido.

    Provisionamento: JIT resolve a entrada, SCIM resolve a saída

    Provisionamento just-in-time cria ou atualiza o usuário no momento em que a asserção chega. É simples de implementar, não exige que o cliente configure nada além da conexão e cobre o caso comum de funcionário novo que precisa acessar hoje.

    O problema do JIT é o inverso: ele não sabe nada sobre quem saiu. Um funcionário desligado no diretório corporativo continua com usuário ativo no produto até alguém lembrar de removê-lo. Para produtos que guardam dado sensível, isso é achado de auditoria garantido.

    Critério JIT via SAML SCIM
    Esforço de implementação Baixo — aproveita o fluxo de login Alto — API REST completa de usuários e grupos
    Criação de conta No primeiro acesso Antes do primeiro acesso
    Desligamento Não detecta Desativa em minutos
    Sincronia de grupos e papéis Só o que vem na asserção, a cada login Contínua, incluindo remoção de grupo
    Exige configuração do cliente Mínima Token, endpoint e mapeamento no diretório

    A escolha prática: entregar JIT primeiro e tratar SCIM como item de roadmap com gatilho comercial claro — o primeiro cliente que colocar desprovisionamento automático em requisito de contrato. Enquanto SCIM não existe, o mitigante honesto é sessão curta para contas SAML, com revalidação contra o provedor a cada poucas horas em vez de token de trinta dias. Não é equivalente a desprovisionamento, mas reduz a janela de exposição de semanas para horas.

    Sobre mapear papéis a partir de grupos do diretório: funciona, e é o que o cliente vai pedir. O cuidado é definir precedência explícita. Se o papel vem do grupo, um administrador que perde o grupo deve perder o papel no login seguinte — inclusive o último administrador da conta. Deixar uma conta sem nenhum administrador por causa de mudança de grupo é o tipo de bug que só aparece em produção. Reservar ao menos um papel de administrador imune ao mapeamento resolve.

    A senha que já existe é o maior risco do projeto

    Ligar SAML é fácil. Desligar o que existia é onde o projeto trava. Uma organização com centenas de usuários ativos tem sessões abertas, tokens de API emitidos, convites pendentes, integrações que autenticam com credencial de serviço e pelo menos uma pessoa que entra por link salvo direto no formulário de senha.

    O caminho que funciona tem três estágios controlados por configuração da organização, não por deploy:

    • SSO opcional. Os dois métodos convivem. Serve para a organização validar o mapeamento de atributos com usuários reais antes de comprometer o acesso de todo mundo.
    • SSO obrigatório com exceções. O login por senha é bloqueado para os usuários do domínio verificado, com lista curta de exceções nominais — tipicamente contas de serviço e o administrador que configurou a conexão.
    • SSO exclusivo. Nenhuma exceção. Só entra aqui a organização que já validou o estágio anterior por algumas semanas.

    Duas armadilhas específicas. A primeira: ao tornar o SSO obrigatório, as sessões já ativas continuam válidas — o usuário só descobre o problema quando a sessão expira, possivelmente semanas depois, e o suporte recebe o chamado descolado da mudança. Invalidar as sessões existentes no momento da troca de estágio é mais gentil do que parece. A segunda: tokens de API pessoais emitidos antes da migração sobrevivem ao bloqueio de senha e são, na prática, uma porta paralela. Ou eles são revogados junto, ou o cliente que pediu SSO obrigatório recebeu menos do que acha que comprou.

    Convites pendentes precisam de tratamento próprio. Um convite emitido antes da conexão existir leva a um formulário de definição de senha que não faz mais sentido. Reescrever o destino do convite no momento em que a conexão é ativada evita o suporte previsível de segunda-feira.

    Checklist de corte

    O que precisa estar verificado antes de liberar a conexão para a primeira organização real, em ordem de execução:

    • Certificado do provedor armazenado com data de expiração e alerta agendado.
    • Suporte a dois certificados simultâneos na mesma conexão, para o cliente rotacionar sem janela de indisponibilidade.
    • Domínio verificado por DNS, com revalidação periódica agendada.
    • Validação completa da asserção, incluindo cache de uso único do ID.
    • Tolerância de relógio configurada e monitorada — divergência de horário é a causa mais comum de falha intermitente que não reproduz em teste.
    • Tela de erro que diz qual validação falhou, em linguagem que o administrador do cliente entende, sem expor conteúdo da asserção.
    • Log de auditoria com organização, identificador externo, provedor, resultado e motivo da falha — retido pelo prazo que o contrato exige.
    • Caminho de recuperação documentado para quando o provedor do cliente cai: quem pode reativar login por senha, com qual aprovação e em quanto tempo.
    • Teste de carga no endpoint de callback. Ele recebe rajada no início do expediente, quando a empresa inteira chega ao mesmo tempo.

    O último item da lista é o que costuma faltar. Uma conexão SAML transfere a disponibilidade do login para um sistema que não é o seu, operado por um time que você não conhece, com janela de manutenção que ninguém avisa. O plano de contingência não é opcional — e precisa estar escrito antes do primeiro cliente depender dele, porque escrever procedimento de emergência durante a emergência é como esse projeto vira post-mortem.

  • Runner de CI hospedado ou self-hosted: onde o custo deixa de ser o critério

    A fila de build passou de dez minutos de espera antes do primeiro job começar a rodar, a fatura de minutos de CI dobrou em dois trimestres e alguém na retrospectiva sugeriu subir runners próprios. A decisão parece financeira, mas o custo por minuto é o critério menos confiável dos três que realmente pesam: tempo de manutenção, superfície de ataque e previsibilidade de performance.

    Este comparativo trata dos dois modelos de execução de pipeline — runner gerenciado pelo provedor de CI e runner self-hosted em infraestrutura própria — assumindo GitHub Actions, GitLab CI ou equivalente. A mecânica é a mesma nos três.

    O que muda tecnicamente entre os dois modelos

    Runner hospedado entrega uma máquina virtual efêmera, criada do zero a cada job e destruída no fim. Imagem padronizada, cache de dependências gerenciado pelo provedor, isolamento garantido entre execuções. O time não administra nada e não controla nada: nem versão de kernel, nem tipo de disco, nem quanto tempo a VM leva para subir.

    Runner self-hosted é um processo agente rodando em máquina do time — bare metal, VM em nuvem, pod em Kubernetes — que faz long-polling na API do provedor esperando job. Quem define CPU, memória, disco, imagem base e política de limpeza é o time. O agente pode ser efêmero (sobe um pod por job e morre) ou persistente (a mesma máquina executa milhares de jobs em sequência). Essa escolha entre efêmero e persistente pesa mais do que a escolha entre hospedado e self-hosted, e é onde a maioria dos times erra.

    Runner persistente é rápido porque o cache de camadas Docker, os módulos de dependência e os artefatos de build sobrevivem entre execuções. Também é o modelo que acumula lixo de disco, mantém estado entre jobs de branches diferentes e transforma qualquer job comprometido em acesso persistente à máquina.

    A conta real, item por item

    Comparar preço de minuto contra preço de instância dá um resultado enganoso, porque as duas colunas medem coisas diferentes. A tabela abaixo separa o que cada modelo cobra e o que cada modelo consome de gente.

    Dimensão Runner hospedado Runner self-hosted
    Modelo de cobrança Por minuto de execução, com faixa gratuita mensal Custo da instância enquanto ligada, independente de haver job
    Ociosidade Custo zero Custo integral, salvo autoescala agressiva
    Tempo até o primeiro comando Dezenas de segundos, sujeito a fila em horário de pico Praticamente imediato em runner persistente
    Cache entre execuções Depende de upload/download para o storage do provedor Local, sem transferência de rede
    Máquinas grandes ou GPU Disponível em tiers caros Livre, limitado só pelo orçamento de infra
    Manutenção recorrente Nenhuma Patch, atualização do agente, limpeza de disco, monitoramento
    Isolamento entre jobs Total, por construção Só com runner efêmero, e por decisão explícita

    A linha que quase sempre decide é a segunda. Pipeline de time pequeno concentra execução em janelas curtas — meio da manhã, fim da tarde — e fica parado o resto do dia. Instância ligada 24 horas para atender três horas de carga é desperdício que o preço por minuto do provedor dificilmente supera. O ponto de virada aparece quando a utilização passa de algo em torno de um terço do dia, ou quando o job precisa de máquina que o tier hospedado só oferece em faixa cara.

    Vale medir antes de estimar. O tempo agregado de execução por mês está exposto na API do provedor e a soma real costuma surpreender: builds paralelos de matriz e jobs que rodam a cada push em branch de feature representam boa parte da fatura, e frequentemente podem ser cortados sem migrar nada.

    Antes de decidir, extraia o tempo real de execução por workflow dos últimos 90 dias e ordene por consumo. Em GitHub Actions: gh api /repos/ORG/REPO/actions/runs --paginate -q '.workflow_runs[] | [.name, .run_started_at, .updated_at] | @tsv' — a diferença entre os dois timestamps, somada por workflow, mostra onde os minutos estão indo. Costuma ser um único workflow respondendo pela maior parte da conta.

    O custo que não aparece na planilha

    Self-hosted transfere para o time responsabilidades que o provedor absorvia em silêncio. Nenhuma delas é difícil isoladamente; juntas consomem uma fração recorrente do tempo de alguém, e essa fração raramente entra na comparação.

    • Disco. Runner persistente acumula imagens Docker, node_modules, artefatos e diretórios de workspace. Sem docker system prune agendado e política de retenção, o disco enche e o sintoma é build falhando de madrugada por espaço, não por código.
    • Versão do agente. O provedor deprecia versões antigas e o runner simplesmente para de receber jobs. Atualização precisa ser automatizada ou vira incidente.
    • Deriva de imagem. A máquina de produção do CI acumula pacotes instalados manualmente para resolver um build específico. Seis meses depois ninguém sabe reconstruir o ambiente, e o pipeline passou a depender de estado não versionado.
    • Autoescala. Manter custo baixo exige escalar de zero — via Actions Runner Controller no Kubernetes, grupo de autoescala com o agente no user data ou equivalente. É infraestrutura própria, com seus próprios modos de falha.

    Regra prática: se ninguém no time tem tempo alocado para tratar o CI como serviço de produção, o self-hosted vai degradar. Não de imediato — em três ou quatro meses, quando a primeira falha de disco acontecer num sábado.

    Segurança: o ponto em que o comparativo deixa de ser sobre custo

    Runner self-hosted persistente executando workflow disparado por pull request de fork é a configuração mais perigosa dessa lista. Qualquer pessoa abre um PR alterando o arquivo de workflow, o job roda com o código dela na máquina do time e o processo tem acesso à rede interna, ao cache local e a tudo que jobs anteriores deixaram no disco. A documentação dos provedores desaconselha explicitamente essa combinação, e ela continua comum em repositório que virou público sem revisão do pipeline.

    Três mitigações, em ordem de eficácia:

    • Runner efêmero, um por job, sem reuso de máquina — resolve o problema estrutural em vez de contorná-lo.
    • Exigir aprovação manual para workflows disparados por colaboradores externos, configuração disponível nas próprias settings do repositório.
    • Isolar o runner em sub-rede sem rota para ambientes internos, com credenciais de curta duração via OIDC em vez de secrets estáticos.

    Runner hospedado elimina essa classe inteira de risco porque a VM nasce e morre com o job. Em compensação, cria dependência da postura de segurança do provedor e obriga a expor endpoints internos caso o pipeline precise alcançar rede privada — o que normalmente leva a um túnel ou a um runner self-hosted só para os jobs de deploy.

    O arranjo que costuma vencer

    Na maioria dos times que mantêm sistema em produção, o desenho híbrido resolve melhor que qualquer extremo: runner hospedado para tudo que é disparado por push e por PR — lint, teste unitário, build de imagem — e um pool self-hosted pequeno, efêmero, com acesso à rede privada, exclusivo para jobs de deploy e de teste de integração que precisam de banco interno.

    Um exemplo de segmentação por label, direto no workflow:

    jobs:
      test:
        runs-on: ubuntu-latest        # hospedado, efemero por construcao
      deploy:
        runs-on: [self-hosted, linux, deploy, ephemeral]
        environment: production        # exige aprovacao antes de executar

    Essa separação mantém a superfície de ataque restrita a jobs que só rodam a partir da branch principal, deixa o volume de execução no modelo que cobra por uso e evita administrar frota de máquinas para rodar teste unitário.

    Migrar tudo para self-hosted se justifica em dois cenários específicos: build que depende de hardware indisponível no tier hospedado — compilação nativa pesada, GPU, macOS em volume — ou utilização sustentada alta o suficiente para que a instância reservada saia mais barata que o minuto avulso com folga confortável. Fora desses casos, o ganho financeiro é pequeno e o passivo operacional é permanente.

    Próximo passo concreto: extraia o consumo por workflow dos últimos 90 dias, identifique o job que responde pela maior fatia e verifique se ele precisa mesmo rodar a cada push. Reduzir gatilho e paralelismo desnecessário costuma cortar mais da conta do que trocar de modelo de execução — e não adiciona nenhuma máquina para manter.

  • Idempotência em APIs de pagamento: o que quebra quando o cliente reenvia a mesma requisição

    Um POST /charges estoura o timeout do cliente em 30 segundos. O gateway, do outro lado, processou a cobrança normalmente aos 34 segundos. O cliente não recebeu resposta, aplica a política de retry e envia a mesma requisição. Resultado: duas cobranças no cartão do usuário final, um chamado de estorno que custa mais caro que o ticket e uma reconciliação manual no fim do mês.

    Idempotência é a resposta padrão, e quase toda equipe implementa alguma versão dela. O problema é que a implementação intuitiva — consultar se o registro já existe antes de inserir — falha exatamente na situação que motivou a solução: duas requisições concorrentes, ainda em voo, disputando o mesmo recurso. O que segue destrincha cada decisão de projeto e o critério de escolha.

    O que a retentativa quebra, em ordem de gravidade

    Nem toda repetição tem o mesmo custo. Vale separar três níveis antes de decidir quanta complexidade o sistema comporta.

    O primeiro nível é o efeito colateral duplicado e irreversível: cobrar duas vezes, emitir dois boletos, disparar duas transferências. Aqui o dano é financeiro e visível para o usuário final. É o caso que justifica praticamente qualquer investimento de engenharia.

    O segundo é o efeito duplicado reversível: dois e-mails de confirmação, dois webhooks para o parceiro. O dano é absorvível, mas corrói integrações — parceiros que recebem eventos duplicados passam a construir deduplicação do lado deles, o que empurra o problema para fora.

    O terceiro é o estado inconsistente parcial, o mais insidioso. A primeira requisição criou a cobrança, chamou o adquirente, gravou a transação e morreu antes de atualizar o saldo. A segunda entra, encontra a cobrança criada, assume que está tudo feito e retorna sucesso. O saldo nunca é atualizado e ninguém percebe até o fechamento. Idempotência mal implementada produz esse cenário com mais frequência do que a ausência total de tratamento, porque cria a ilusão de que o caso já foi coberto.

    A conclusão prática: idempotência não é uma verificação de duplicidade, é uma garantia de que o resultado observável de N execuções da mesma requisição é igual ao de uma execução. Isso inclui devolver a mesma resposta, com o mesmo corpo e o mesmo status, não apenas evitar o segundo insert.

    A chave de idempotência: quem gera, o que ela cobre, quanto tempo dura

    A chave é um identificador que o cliente gera e envia em um cabeçalho, normalmente Idempotency-Key, e que o servidor usa para reconhecer que duas requisições são a mesma tentativa lógica.

    Gerar a chave no servidor não funciona: se o servidor gerasse, a segunda requisição receberia uma chave diferente e seria tratada como nova. A chave precisa nascer no ponto em que a intenção do usuário se forma — a tela de checkout, o job que dispara a cobrança — e permanecer estável por todas as retentativas daquela intenção. Um UUID v4 gerado no início do fluxo resolve. Derivar a chave de um hash do corpo da requisição é tentador e problemático: dois pagamentos legítimos e idênticos do mesmo valor, no mesmo minuto, colidiriam.

    Sobre o escopo, a chave deve ser única por cliente da API e por endpoint, não globalmente. Escopo global cria acoplamento desnecessário entre integradores e transforma colisão acidental em incidente de segurança — um cliente conseguiria ler a resposta de outro se adivinhasse a chave. A tabela de idempotência deve ter chave composta por identificador do consumidor, rota e chave enviada.

    O tempo de retenção é onde a maioria dos projetos erra por excesso ou por falta. Reter por 24 horas cobre a janela realista de retry de qualquer cliente bem-comportado e a maior parte dos incidentes de rede. Reter para sempre transforma a tabela em um passivo que cresce indefinidamente e passa a dominar o custo de escrita. Reter por 5 minutos deixa de fora o cliente que ficou offline e reprocessou a fila meia hora depois. A faixa de 24 a 72 horas é o consenso na maior parte dos gateways de pagamento; abaixo disso é preciso ter certeza sobre o comportamento de retry de todos os consumidores.

    Uma regra que economiza incidente: a chave precisa cobrir também o corpo da requisição. Se a mesma chave chegar com um payload diferente — valor alterado, cartão diferente —, o correto é responder 422 em vez de devolver a resposta antiga. Isso captura bug de cliente que reutiliza chave por engano, que é uma classe de erro bem mais comum que a retentativa legítima.

    Guarde o hash do corpo junto com a chave e compare na entrada: sha256(body) != registro.body_hash retorna 422 com mensagem explícita. Sem essa comparação, um cliente com bug consegue receber a resposta de uma cobrança de R$ 10 ao tentar cobrar R$ 1.000, e o erro só aparece na conciliação.

    Onde guardar o registro: banco relacional, cache distribuído ou o próprio recurso

    Existem três lugares plausíveis para materializar a chave, com trade-offs bem diferentes.

    Estratégia Garantia Custo Quando usar
    Tabela dedicada no mesmo banco transacional Forte: a chave e o efeito colateral entram na mesma transação Uma escrita a mais por requisição; tabela precisa de expurgo Efeito colateral irreversível e banco relacional já no caminho crítico
    Redis com SET NX e TTL Fraca sob falha: a chave pode gravar e o efeito não, ou vice-versa Baixo, latência de milissegundos Deduplicação de efeitos reversíveis — e-mail, webhook, notificação
    Constraint única no próprio recurso Forte, sem tabela extra Zero de infraestrutura adicional Quando existe um identificador natural de negócio já enviado pelo cliente

    Para dinheiro, a escolha é a primeira. O ponto decisivo é a atomicidade: gravar a chave em um sistema (Redis) e o efeito em outro (Postgres) reintroduz o problema de dois commits sem transação distribuída. Se o processo cair entre os dois, o sistema fica em um estado em que a chave existe mas a cobrança não — e a retentativa passa a receber uma resposta de sucesso para algo que nunca aconteceu. É o terceiro nível de gravidade descrito acima, agora causado pela própria proteção.

    A terceira opção é subestimada. Quando o domínio já tem um identificador que o cliente controla — número de pedido, referência externa do parceiro, código de remessa —, uma unique constraint nesse campo entrega idempotência sem tabela auxiliar, sem TTL e sem código de limpeza. A limitação é que ela não guarda a resposta original: na segunda tentativa o servidor descobre a violação de constraint e precisa buscar o recurso existente para responder o mesmo corpo. Para APIs cujo retorno é o próprio recurso, isso é suficiente e vale a simplicidade.

    O caso difícil: a segunda requisição chega com a primeira ainda em voo

    Esse é o cenário que separa implementação real de implementação de exemplo. O cliente deu timeout aos 30 segundos e reenviou; a primeira requisição continua rodando no servidor, chamando o adquirente, e vai levar mais 10 segundos.

    A verificação ingênua — select, e se não achar, processa — coloca as duas requisições no mesmo caminho, porque no instante do select a primeira ainda não escreveu nada. O resultado é a cobrança dupla que a idempotência deveria impedir.

    A correção é inverter a ordem: escrever a chave primeiro, em estado em_andamento, e deixar o banco decidir quem ganhou a corrida. Quem consegue inserir processa; quem recebe violação de unicidade sabe que perdeu e responde de acordo.

    -- 1. tentativa de reserva; quem perder recebe violação de unicidade
    INSERT INTO idempotencia (consumidor_id, rota, chave, body_hash, estado)
    VALUES ($1, $2, $3, $4, 'em_andamento');
    
    -- 2. processa o efeito e finaliza na MESMA transação
    UPDATE idempotencia
       SET estado = 'concluido',
           status_http = $5,
           resposta = $6,
           concluido_em = now()
     WHERE consumidor_id = $1 AND rota = $2 AND chave = $3;

    Resta definir o que responder ao perdedor da corrida. Há duas escolhas defensáveis e uma ruim.

    A ruim é bloquear e esperar a primeira terminar para devolver a mesma resposta. Parece elegante e ocupa um worker por toda a duração da requisição original — sob incidente, quando todo mundo está reprocessando, isso esgota o pool de conexões e derruba o serviço inteiro. É o mecanismo clássico de amplificação de falha.

    A primeira escolha defensável é responder 409 Conflict imediatamente, sinalizando que aquela chave está em processamento e que o cliente deve tentar de novo em alguns segundos. É simples, libera o worker na hora e transfere a espera para o cliente, que é quem tem o contexto para decidir.

    A segunda é responder 202 Accepted com um identificador de acompanhamento, tornando o endpoint assíncrono nesse caso. Mais trabalho dos dois lados, melhor para operações longas.

    Entre as duas: 409 para APIs síncronas cujo processamento fica na casa de segundos, que é a maioria; 202 quando a operação envolve fila, terceiro lento ou processamento que pode passar de dez segundos. O critério não é a elegância, é quanto tempo um worker fica preso durante um pico de retentativas.

    Há ainda o registro órfão: o processo morreu depois do insert em em_andamento e nunca concluiu. Sem tratamento, aquela chave fica travada em 409 para sempre. A saída é considerar expirado todo registro em_andamento mais velho que o timeout máximo da operação — algo entre 60 e 120 segundos para APIs síncronas — e permitir que uma nova tentativa assuma o lugar.

    O que verificar antes de considerar pronto

    Testes de idempotência que passam sempre costumam ser testes sequenciais, e sequência é justamente o caso fácil. A bateria mínima tem cinco cenários, e três deles exigem concorrência real.

    • Mesma chave, mesmo corpo, sequencial: a segunda chamada devolve status e corpo idênticos aos da primeira, sem novo efeito colateral.
    • Mesma chave, mesmo corpo, concorrente: N chamadas disparadas em paralelo produzem exatamente um efeito; as demais recebem 409 ou a resposta gravada.
    • Mesma chave, corpo diferente: retorna 422 e não executa nada.
    • Falha no meio: matar o processo entre o insert da chave e a conclusão deve deixar o sistema em estado recuperável — a retentativa após a janela de expiração processa normalmente, sem duplicar.
    • Chave expirada: passada a janela de retenção, a mesma chave é tratada como requisição nova. Confirme que isso é aceitável para o domínio antes de encurtar o TTL.

    O quarto cenário é o que mais revela defeito e o menos testado, porque exige injetar falha no meio da transação. Vale o esforço de escrever um teste que aborte a conexão logo após o primeiro insert: é ele que diferencia um sistema que sobrevive a deploy no meio do pico de um que gera pendência de conciliação.

    O próximo passo natural, com a idempotência de escrita estabilizada, é aplicar a mesma disciplina ao consumo de eventos — webhooks recebidos de terceiros carregam garantia at-least-once e chegam repetidos por definição, com o agravante de que a chave, ali, é escolhida por quem envia.

  • Post-mortem: a migração de coluna que derrubou o checkout por 38 minutos

    Um ALTER TABLE para adicionar uma coluna com NOT NULL DEFAULT em uma tabela de pedidos com dezenas de milhões de linhas, executado às 14h de uma terça, com o checkout recebendo tráfego. O resultado: 38 minutos de erro 500 em finalização de compra. O caso abaixo é uma reconstrução hipotética, montada a partir de padrões de falha que se repetem em times que rodam migração junto com o deploy da aplicação. Serve para separar o que é limitação do banco do que é falha de processo.

    A linha do tempo do incidente

    O deploy subiu às 14h02. A migração era considerada trivial: uma coluna nova em pedidos, com valor padrão, para marcar o estado do pagamento. Em ambiente de homologação, com uma base de poucos milhares de registros, a migração rodou em menos de um segundo.

    Em produção, a sequência foi outra:

    • 14h02 — o runner de migração abre transação e emite o ALTER TABLE.
    • 14h02 — o comando adquire um lock exclusivo (ACCESS EXCLUSIVE, no vocabulário do PostgreSQL) sobre a tabela.
    • 14h03 — toda consulta nova em pedidos, inclusive SELECT, entra na fila atrás do lock.
    • 14h05 — o pool de conexões da aplicação satura. Requisições que nem tocam pedidos começam a falhar por indisponibilidade de conexão.
    • 14h11 — o alerta de taxa de erro dispara. O time ainda procura o problema no serviço de pagamento.
    • 14h33 — alguém consulta pg_stat_activity e encontra a migração ainda em execução, com uma fila de centenas de sessões em lock wait.
    • 14h40 — a migração é cancelada e sofre rollback. O sistema normaliza em dois minutos.

    Duas coisas importam nessa linha do tempo. A primeira é que o incidente durou 38 minutos, mas o diagnóstico consumiu 30 deles. A segunda é que o efeito extrapolou a tabela afetada: a saturação do pool transformou um problema localizado em queda geral.

    Por que o ALTER TABLE trava a tabela inteira

    A causa raiz não é o ALTER TABLE em si. Versões recentes de PostgreSQL adicionam coluna com valor padrão sem reescrever a tabela — o default fica registrado no catálogo e é materializado só na próxima escrita da linha. A operação é rápida.

    O problema é o que acompanha o comando. Três armadilhas explicam a maioria dos casos:

    O lock é exclusivo mesmo quando a operação é rápida. Para pegar o lock, o comando precisa esperar que todas as transações abertas sobre a tabela terminem. Basta uma consulta analítica de dois minutos, ou uma transação esquecida em estado idle in transaction, para o ALTER ficar bloqueado. E, uma vez na fila, ele bloqueia todo mundo que chega depois — inclusive leituras. Uma operação de 50 milissegundos vira uma parada de meia hora por causa de uma transação que ninguém fechou.

    Nem toda variação evita a reescrita. Adicionar coluna com default constante é barato. Adicionar com default calculado por função volátil, mudar tipo de coluna, ou adicionar uma constraint CHECK sem a cláusula NOT VALID força varredura ou reescrita completa. A diferença entre o caso barato e o caro está em detalhes de sintaxe que passam despercebidos em revisão de código.

    Migração e deploy compartilham o mesmo timeout. Se o runner de migração roda sem lock_timeout, ele espera indefinidamente. O comportamento desejado é o oposto: falhar rápido e tentar de novo.

    Rodar toda migração com limites explícitos muda o modo de falha. Em PostgreSQL: SET lock_timeout = '3s'; SET statement_timeout = '30s'; antes do DDL. A migração passa a falhar em três segundos em vez de bloquear a tabela por meia hora — e a falha some do caminho crítico assim que o runner tenta de novo.

    Expand e contract: a migração em quatro deploys

    A correção estrutural é parar de tratar mudança de schema como evento único. O padrão expand–contract divide a alteração em etapas nas quais schema antigo e novo coexistem, de modo que nenhum deploy exija que banco e aplicação mudem no mesmo instante.

    Para a coluna do incidente, a sequência fica assim:

    Deploy 1 — expandir. Adicionar a coluna como nullable, sem default e sem constraint. É a operação mais barata que existe: só catálogo, sem reescrita. A aplicação ainda não conhece a coluna.

    ALTER TABLE pedidos ADD COLUMN status_pagamento varchar(32);

    Deploy 2 — escrever nos dois lugares. A aplicação passa a preencher a coluna nova em toda escrita, mas continua lendo do campo antigo. Nenhuma leitura depende ainda do dado novo, então um bug aqui não quebra o fluxo do usuário.

    Deploy 3 — backfill e virada da leitura. Preencher as linhas antigas em lotes (detalhado na seção seguinte) e, só depois de o backfill terminar e ser verificado, mudar a leitura para a coluna nova.

    Deploy 4 — contrair. Remover a escrita dupla, aplicar a constraint NOT NULL e derrubar o campo antigo. A constraint entra em duas etapas para não varrer a tabela sob lock exclusivo:

    ALTER TABLE pedidos
      ADD CONSTRAINT pedidos_status_pagamento_nn
      CHECK (status_pagamento IS NOT NULL) NOT VALID;
    
    ALTER TABLE pedidos
      VALIDATE CONSTRAINT pedidos_status_pagamento_nn;

    A primeira instrução registra a constraint sem checar as linhas existentes — rápida, lock curto. A segunda varre a tabela, mas com um lock que não bloqueia leitura nem escrita. O custo é ter uma constraint CHECK onde se queria um NOT NULL de coluna; em compensação, o processo nunca para o sistema.

    O custo real do expand–contract é organizacional: quatro deploys em vez de um, e uma janela de dias em que o código carrega escrita dupla. Vale a pena para qualquer tabela grande com tráfego. Não vale para tabela de configuração com centenas de linhas — nesse caso o ALTER direto, com lock_timeout curto, resolve.

    Backfill: o detalhe que quase repetiu o incidente

    Depois do incidente, o time escreveu o backfill como um único UPDATE. Isso reproduz o problema em outra forma: um UPDATE que toca dezenas de milhões de linhas em uma transação gera uma transação longuíssima, infla o WAL, atrasa a replicação e impede o autovacuum de limpar versões mortas de linha. O sistema não trava, mas degrada por horas.

    Backfill correto é feito em lotes, com pausa entre eles e commit por lote:

    UPDATE pedidos
    SET status_pagamento = CASE
          WHEN pago_em IS NOT NULL THEN 'pago'
          ELSE 'pendente'
        END
    WHERE id IN (
      SELECT id FROM pedidos
      WHERE status_pagamento IS NULL
      ORDER BY id
      LIMIT 5000
    );

    O laço roda até nenhuma linha ser afetada. Três parâmetros governam o comportamento: tamanho do lote, pausa entre lotes e critério de parada. Lotes na casa de alguns milhares de linhas costumam ser o ponto de equilíbrio — grandes o bastante para terminar em tempo razoável, pequenos o bastante para que cada transação dure milissegundos. A pausa entre lotes existe para dar espaço ao autovacuum e à replicação; começar com algo em torno de cem milissegundos e ajustar observando o atraso de réplica é mais confiável do que fixar um número no código.

    O critério de parada merece atenção especial. Um backfill que roda por horas precisa poder ser interrompido e retomado sem perder progresso — daí filtrar por status_pagamento IS NULL em vez de paginar por offset. Se o processo morrer no meio, basta rodar de novo.

    Comparativo: quando usar cada estratégia

    Estratégia Cabe quando Custo Risco de indisponibilidade
    ALTER direto no deploy Tabela pequena, sem tráfego contínuo Um deploy Alto se a tabela crescer sem ninguém revisar
    ALTER com lock_timeout e retry Tabela média, operação que só mexe no catálogo Um deploy mais lógica de retry Baixo — falha rápido em vez de bloquear
    Expand–contract com backfill em lotes Tabela grande com tráfego, mudança que altera dados Três a quatro deploys ao longo de dias Muito baixo
    Ferramenta de migração online MySQL, ou mudança de tipo em tabela muito grande Ferramenta externa no pipeline, espaço em disco extra Baixo, com complexidade operacional maior

    Entre as quatro, a que mais se ganha adotando por padrão é a segunda. Colocar lock_timeout em toda migração é uma linha de configuração no runner e elimina a classe inteira de incidentes em que uma operação barata fica presa atrás de uma transação esquecida. O expand–contract é mais trabalhoso e deve ser reservado a mudanças que realmente mexem em dados de tabela grande.

    O que entra no runbook

    Três mudanças de processo evitam a repetição, e nenhuma delas depende de o desenvolvedor lembrar da regra na hora certa:

    • Migração fora do deploy da aplicação. Enquanto os dois compartilham o mesmo passo do pipeline, uma migração lenta segura o rollback do código. Separar os dois permite reverter a aplicação sem tocar no banco.
    • Limite de tamanho na revisão. Um teste no CI que consulta o número estimado de linhas da tabela alvo e exige aprovação explícita acima de um limiar — algo na casa do milhão de linhas — transforma a regra em barreira automática.
    • Monitor de transação longa. Um alerta para sessões em idle in transaction acima de alguns minutos. Foi uma dessas que segurou o lock no incidente, e ela já estava aberta antes do deploy começar.

    O próximo passo prático é levantar as três maiores tabelas do sistema e verificar quais migrações do último trimestre teriam sido bloqueantes nelas. O padrão costuma aparecer rápido: quase sempre são duas ou três tabelas que concentram o risco, e é nelas que o expand–contract precisa ser obrigatório.

  • Checklist de Segurança para APIs REST Antes de Ir para Produção

    Uma API que passou nos testes funcionais ainda pode estar aberta o suficiente para ser explorada no primeiro dia em produção. A verificação de segurança é uma etapa própria, com critérios que não se confundem com “funciona”: autenticação correta, autorização por recurso, limites de uso e controle do que a resposta devolve. Este texto lista o que precisa estar fechado antes de expor uma API REST na internet e onde as decisões costumam ser tomadas errado.

    Autenticação e autorização são duas verificações separadas

    Confirmar quem faz a chamada e confirmar que esse alguém pode acessar aquele recurso são checagens independentes. A maior parte das falhas graves de API não está na autenticação — o token é válido — e sim na autorização: o endpoint devolve o pedido de número 4821 sem verificar se ele pertence ao usuário autenticado. Isso é o clássico Broken Object Level Authorization, o item que lidera as listas de vulnerabilidades de API há anos.

    A regra prática: toda rota que recebe um identificador de recurso precisa validar propriedade no servidor, nunca confiar que o cliente só vai pedir o que é dele. Não adianta esconder o botão no front-end. Se o recurso é acessível por GET /pedidos/4821, alguém vai trocar o número na mão.

    @app.get("/pedidos/{pedido_id}")
    def obter_pedido(pedido_id: int, usuario = Depends(auth)):
        pedido = repo.buscar(pedido_id)
        if pedido is None or pedido.dono_id != usuario.id:
            raise HTTPException(status_code=404)  # 404, nao 403
        return pedido

    Devolver 404 em vez de 403 quando o recurso existe mas não pertence ao usuário evita confirmar a existência do objeto para quem não deveria enxergá-lo. É uma escolha deliberada, não descuido. A autorização por atributo de nível de campo é o passo seguinte que muita API pula: o mesmo endpoint pode ser legítimo para dois papéis, mas um administrador enxerga o campo de custo interno e o cliente final não. Filtrar isso na saída, por papel, evita que a permissão de rota vire permissão de dado.

    Escolha do mecanismo de token e sua revogação

    A decisão entre JWT autocontido e token opaco com consulta no servidor tem um trade-off que decide o resto da arquitetura de sessão. O JWT dispensa consulta a cada requisição, o que escala bem, mas não há como invalidá-lo antes de expirar sem reintroduzir estado — uma blocklist ou uma verificação de versão. O token opaco custa uma consulta por chamada, geralmente a um cache em memória, e em troca a revogação é imediata.

    Critério JWT autocontido Token opaco + store
    Custo por requisição Verificação de assinatura Consulta ao store
    Revogação imediata Exige estado extra Nativa
    Vazamento de payload Dados legíveis no token Nada exposto
    Melhor cenário Muitos serviços, expiração curta Sessão que precisa de logout real

    Para a maioria dos sistemas com login de usuário e necessidade de “encerrar sessão agora”, o token opaco com store de baixa latência é a escolha mais segura. JWT com expiração de 15 minutos e refresh rotativo resolve o caso de arquitetura distribuída onde a consulta central seria gargalo. O erro comum é adotar JWT longo por conveniência e descobrir tarde que não há como derrubar um token comprometido.

    Comunicação entre serviços internos é um caso à parte: token de usuário não deve trafegar de serviço para serviço como se fosse credencial de máquina. Aqui entram mTLS ou credenciais de cliente de curta duração, com escopo restrito ao que aquele serviço precisa chamar. Reaproveitar o token do usuário em toda a cadeia de chamadas amplia o estrago de um único vazamento para o sistema inteiro.

    Rate limiting e proteção contra abuso

    Sem limite de taxa, um endpoint de autenticação vira alvo de força bruta e qualquer rota cara vira vetor de negação de serviço. O limite precisa existir em duas camadas: uma global por IP na borda e uma por identidade autenticada na aplicação, com janelas diferentes para rotas sensíveis. Login e recuperação de senha pedem limites bem mais rígidos que uma leitura de catálogo.

    Um limitador por janela deslizante com Redis: INCR chave na primeira chamada, EXPIRE chave 60 junto, e recusa quando o contador passa do teto. Chave no formato rl:{rota}:{user_id} para separar o orçamento de cada rota por usuário, em vez de um balde único que a leitura esvazia antes de o login precisar.

    Retorne 429 com o cabeçalho Retry-After para que clientes legítimos saibam recuar. E registre o estouro: uma sequência de 429 em recuperação de senha vindo de um mesmo intervalo de IPs é sinal de ataque em andamento, não de usuário distraído. Em ambiente com várias instâncias da API, o contador precisa ser compartilhado — um limitador em memória local vaza porque cada réplica conta o seu próprio balde e o teto efetivo vira o teto multiplicado pelo número de instâncias.

    Validação de entrada e a superfície de injeção

    Toda entrada é hostil até prova em contrário. Isso vale para o corpo da requisição, parâmetros de query, cabeçalhos e o próprio caminho. A validação por esquema — tamanho máximo, tipo, formato, faixa de valores — precisa acontecer antes de a lógica de negócio tocar o dado. Bibliotecas de validação declarativa resolvem o grosso, desde que o esquema recuse campos desconhecidos em vez de ignorá-los.

    Consultas ao banco sempre parametrizadas, sem concatenar string de query. Isso elimina a injeção de SQL clássica, mas a mesma disciplina vale para qualquer interpretador que a entrada alcance: comandos de shell, montagem de filtros NoSQL, expressões passadas a um motor de template. Um teto explícito no tamanho do corpo aceito — configurado no servidor de aplicação, não só no proxy — corta o envio de payloads gigantes projetados para estourar memória.

    Um vetor que passa despercebido é o SSRF: qualquer campo em que o usuário fornece uma URL que o servidor vai buscar — webhook, importação por link, geração de miniatura — pode ser apontado para endereços internos da rede, incluindo metadados de nuvem. A defesa é uma lista de permissão de destinos e o bloqueio explícito de faixas privadas e do endereço de metadados, resolvido depois da resolução de DNS para impedir rebind.

    O que a resposta devolve sem querer

    Um vazamento comum não está no que a API protege, e sim no que ela responde. Serializar o objeto inteiro do banco expõe campos que ninguém pediu: hash de senha, flags internas, dados de outro contexto. A resposta precisa ser modelada por um esquema de saída explícito, com os campos permitidos listados um a um, em vez de despejar o modelo de persistência direto no JSON.

    Mensagens de erro merecem o mesmo cuidado. Um stack trace devolvido ao cliente entrega versão de framework, estrutura de diretório e às vezes trecho de query. Em produção, o erro que chega ao cliente é genérico e correlacionado a um identificador; o detalhe fica no log interno. Cabeçalhos que anunciam a stack — X-Powered-By, Server com versão — saem por padrão.

    Segredos, dependências e o checklist de corte

    Chave de API, credencial de banco e segredo de assinatura não moram no código nem em variável commitada. Ficam em um cofre de segredos ou em variável de ambiente injetada no deploy, com rotação prevista. E o log, que é onde muita coisa escapa: senha, token e número de cartão não podem ser gravados, o que exige uma camada de redação antes da escrita, não confiança em quem chama o logger.

    A cadeia de dependências é superfície de ataque tão real quanto o código próprio. Uma verificação automatizada de vulnerabilidades conhecidas nos pacotes, rodando no pipeline e falhando o build quando aparece falha crítica, evita subir para produção com uma biblioteca já sabidamente explorável. Fixar versões e revisar o que entra por transitividade fecha a porta do comprometimento por dependência substituída.

    Antes do deploy, a lista mínima que precisa estar toda marcada: HTTPS obrigatório com redirecionamento de HTTP; CORS restrito a origens conhecidas, sem * em rota autenticada; autorização por recurso testada com um usuário tentando acessar dado de outro; rate limit ativo em login e recuperação; esquema de saída que não vaza campo interno; erros genéricos ao cliente; segredos fora do repositório; dependências sem vulnerabilidade conhecida na verificação automatizada. Nenhum desses itens é opcional para uma API pública, e o barato de fechá-los antes é sempre menor que o custo de responder a um incidente depois que o tráfego real chegou.