Pular para conteúdo

0003 — A API responde antes de comitar a transação

Status CORRIGIDO
Branch fix/0003-api-responde-antes-de-comitar
Aberto em 2026-08-03
Severidade ALTA
Apps afetados api (web e e2e sofrem o efeito; sem mudança neles)

Status: RASCUNHO → DIAGNOSTICADO → EM CORREÇÃO → CORRIGIDO

1. Sintoma

Um POST devolve 201 com o id do registro, e a chamada seguinte que referencia esse id falha com 500 e mensagem genérica:

{"error":{"code":"INTERNAL_ERROR","message":"Ocorreu um erro inesperado em nosso sistema. Tente novamente em alguns instantes. Se o problema persistir, contate o suporte.","details": []}}

No log da API, a causa real:

asyncpg.exceptions.ForeignKeyViolationError: insert or update on table "credit_analyses"
violates foreign key constraint "proposals_property_id_fkey"
DETAIL:  Key (property_id)=(56347edd-3e6c-4078-b2df-ff81444ab380) is not present in table "properties".

O imóvel existe — conferido direto no banco depois do erro, com deleted_at nulo:

56347edd-3e6c-4078-b2df-ff81444ab380 | 2026-08-03 18:15:00.045521+00 |

E o log mostra as duas chamadas em sequência, no mesmo worker (mesma porta de origem):

INFO: 172.20.0.6:33304 - "POST /api/v1/properties HTTP/1.1" 201 Created
INFO: 172.20.0.6:33304 - "POST /api/v1/credit-analyses HTTP/1.1" 500 Internal Server Error

Do ponto de vista de quem usa: cadastrar um imóvel e usá-lo imediatamente pode dar erro de sistema, sem nada de errado com o dado. O usuário só descobre que "deu erro"; repetir funciona.

2. Reprodução

Não é determinística — depende de carga. É honesto registrar isso: a janela do defeito é o intervalo entre a resposta sair e o COMMIT acontecer, e ela só fica maior que o round-trip do cliente quando o servidor está ocupado.

O que reproduziu (uma vez, e é a evidência acima):

  1. cd apps/e2e && npm test — a suíte completa, 3 browsers, ~15 workers em paralelo.
  2. O setupDraftCreditAnalysis de credit-analyses-mobile.spec.ts cria imobiliária → inquilino → imóvel → análise, sem pausa entre as chamadas.
  3. Em uma das execuções o POST /credit-analyses devolveu 500 com a FK violation acima. O mesmo teste, rodado sozinho em seguida, passou.

O que não reproduziu (servidor ocioso, e vale como informação para quem corrigir):

  • 30 cadeias sequenciais imóvel → análise, sem pausa: 0 falhas.
  • 12 cadeias concorrentes × 4 iterações (48 escritas dependentes): 0 falhas.

  • Ambiente: dev-remote (dev.garantora.com.br). Vale igual em local — é código, não infraestrutura.

  • Superfície: API. Não é de tela, desktop ou mobile; sem relação com PWA.
  • Dados/pré-condições: qualquer papel que possa criar. Precisa de carga concorrente para a janela abrir.

3. Esperado × Atual

  • Esperado: quando a API responde 201 com um id, o registro está durável e visível para a requisição seguinte. É o contrato que todo cliente assume.
  • Atual: a resposta sai antes do COMMIT. Uma requisição imediatamente posterior abre outra sessão/conexão e pode não enxergar a linha — que aparece pouco depois.

4. Causa raiz

apps/api/app/db/session.py:18-25 — o único commit da aplicação está depois do yield da dependência:

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()      # ← teardown da dependência
        except Exception:
            await session.rollback()
            raise

No FastAPI, o código depois do yield roda depois de a resposta ser entregue (mudança documentada na 0.106; aqui roda 0.135.3). Então a ordem real é:

  1. handler termina e monta o 201 com o id;
  2. resposta entregue ao cliente;
  3. await session.commit().

Nada compensa isso nas camadas de baixo: BaseRepository.create/save (app/repositories/base.py:124-138) fazem apenas flush + refresh, e não há um único commit explícito em app/services/ ou app/api/v1/routers/ — conferido por varredura. O flush faz o INSERT (por isso o id existe e o refresh funciona), mas a transação segue aberta: para qualquer outra conexão, a linha não existe até o commit.

Sob concorrência o passo 3 pode ficar atrás de outro trabalho no laço de eventos, e é aí que a requisição seguinte chega antes dele.

Não é regressão. O get_db é assim desde o início do projeto; o que mudou foi a exposição — a suíte e2e passou a emendar escritas dependentes em paralelo, e a spec 0015 tornou o padrão comum na aplicação: o cadastro inline do bloco de seleção cria inquilino → imóvel → análise em três POST consecutivos, sem intervenção do usuário no meio. Hoje é o caminho de escrita encadeada mais provável do produto.

5. Correção

apps/api

  • Onde: app/db/session.py (fronteira transacional) e o que for necessário para o commit acontecer dentro do ciclo da requisição.
  • O quê: garantir que o COMMIT termine antes de a resposta ser entregue. Ver §9 — a escolha do mecanismo é a decisão em aberto.
  • Teste: teste de API que prove a ordem. Um caminho é um cliente que faça duas escritas dependentes contra a mesma aplicação e afirme 201 nas duas; melhor ainda é um teste que verifique a visibilidade em outra sessão logo depois do handler retornar, porque não depende de temporização.

apps/web

Sem impacto — o defeito é do contrato da API. Não faz sentido ensinar o cliente a tolerar (retry cego esconderia erro real e duplicaria escrita).

apps/e2e

  • Teste de regressão: o cenário que existe hoje já cobre o padrão (setupDraftCreditAnalysis e o setupAnalysis da spec 0015 emendam imóvel → análise). Não vale escrever teste que dependa de corrida; o teste determinístico é o da API.

6. Risco / impacto colateral

Mover a fronteira transacional atravessa todos os endpoints — é o ponto de atenção principal:

  • Toca dinheiro. Criação de contrato e de cobranças (contract_service, billing_service) grava vários registros por requisição. Se a mudança fragmentar a transação (commit por operação em vez de por requisição), um erro no meio deixaria contrato sem cobranças — hoje o rollback cobre tudo. A atomicidade por requisição não pode se perder.
  • Ordem de erro muda. Erros que hoje aparecem no commit (violação de unicidade, por exemplo) passariam a estourar antes da resposta — o que é melhor, e é parte do objetivo: hoje um IntegrityError no teardown já teve a resposta 201 enviada, e o cliente recebe sucesso para algo que não foi gravado. Esse é o segundo defeito, mais grave que o relatado, e a mesma correção resolve.
  • app/main.py tem handlers globais de erro; se o commit passar para middleware ou classe de rota, é preciso que a exceção continue caindo neles com o mesmo formato de envelope.
  • Rotas que devolvem StreamingResponse (download de documento) merecem conferência: o corpo é produzido depois, e a sessão não pode ser fechada antes da hora.

7. Critérios de aceite

  • [x] CA1. Depois de um 201, a linha é visível para uma sessão nova consultando o banco — sem espera, sem retry.
  • [x] CA2. Duas escritas dependentes emendadas (imóvel → análise) devolvem 201 nas duas, inclusive com a aplicação sob carga concorrente.
  • [x] CA3. Uma violação de banco que hoje explode no commit (ex.: unicidade) passa a produzir erro HTTP com o envelope padrão, e não um 201 seguido de nada gravado.
  • [x] CA4. Atomicidade preservada: erro no meio de uma requisição que grava vários registros (contrato + cobranças) não deixa gravação parcial. Coberto por teste.
  • [x] CA5. make test passando (202 hoje) e nenhum apontamento novo de lint.
  • [x] CA6. Download de documento (StreamingResponse) continua funcionando.

8. Plano

  • [x] Escrever o teste que falha: visibilidade em sessão nova imediatamente após o handler
  • [x] Decidir o mecanismo (§9) e mover o commit para dentro do ciclo da requisição
  • [x] Conferir os handlers globais de erro e o envelope
  • [x] Conferir StreamingResponse (download de documento)
  • [x] Teste de atomicidade multi-registro (contrato + cobranças)
  • [x] make test e make lint sem apontamento novo nos arquivos tocados
  • [x] apps/web sem mudança — registrar que foi conferido

9. Decisões em aberto

  • [x] Qual mecanismo para comitar dentro da requisição? Resolvido em 2026-08-03: classe de rota própria (opção 1).

  • Classe de rota própria (APIRoute) ou middleware que comita a sessão da requisição. Diff pequeno e num lugar só; mantém uma transação por requisição (atomicidade intacta). Precisa expor a sessão em request.state para o commit alcançá-la. É a recomendação.

  • commit explícito no fim de cada método público de serviço. Explícito e fácil de ler, mas mexe em ~40 arquivos e abre espaço para esquecer um — e o esquecido volta a ter o defeito de hoje, silenciosamente.
  • Manter o commit no teardown e fazer o cliente tolerar. Tratar sintoma; além de esconder erro real, um retry cego pode duplicar escrita. Não recomendado.

10. Registro

2026-08-03 — corrigido com CommitRoute. O teste vermelho veio antes, e provou a ordem.

O teste que faltava não podia usar a fixture client. O conftest substitui o get_db por uma versão que devolve a sessão compartilhada do teste e nunca comita — a suíte inteira vive numa transação só. Foi por isso que o defeito atravessou 202 testes sem aparecer, e é por isso que um teste "cria e depois lê" ali não provaria nada.

O tests/test_transaction_boundary.py observa a ordem dos eventos ASGI: chama a aplicação diretamente com um send instrumentado, e a sessão registra o próprio commit na mesma lista. Antes da correção a ordem medida era exatamente a da hipótese:

['http.response.start', 'http.response.body', 'commit']

Depois, o commit vem antes. Conferido nos dois sentidos: com o session.py do HEAD copiado para dentro do container, 2 dos 4 testes falham; com o novo, os 4 passam.

A correção. CommitRoute (app/core/transaction.py) executa o handler, comita a sessão daquela requisição e só então devolve a resposta. O get_db deixou de comitar e passou a guardar a sessão em request.state.db — é como a rota a alcança, já que ela não participa da injeção de dependências do endpoint. O rollback continua no teardown, que é o caminho de exceção.

Duas coisas divergiram do plano:

  1. A classe não podia ficar num lugar só. O include_router do FastAPI recria cada rota preservando a classe do router filho (route_class_override=type(route)), então declarar route_class no router de /api/v1 não alcançaria nada de dentro. Foram os 22 routers mais app.router.route_class no create_app() — este último cobre o que é declarado direto na aplicação, como o /health. Para o esquecimento não passar em silêncio, um teste afirma que toda rota da aplicação usa a classe; um router novo com APIRouter() puro falha a suíte. Foi esse teste que encontrou as duas rotas de /health fora.
  2. O segundo defeito era menos grave do que eu escrevi — e o teste me corrigiu. Eu supunha que uma falha no COMMIT produzia 201 e nada gravado. Na prática o ServerErrorMiddleware do Starlette envia a resposta de erro e reergue a exceção para log, então o cliente já recebia erro. O que era verdade e continua valendo é o resto: com o commit no teardown, o erro chega depois da resposta e não passa pelos handlers de app/main.py. A asserção foi reescrita para medir o status (não 201, e algum 5xx), que é o que interessa a quem chama.

Riscos da §6, verificados:

  • Atomicidade preservada — segue uma transação por requisição; nada foi fragmentado. Os testes de contrato e cobranças passam sem alteração.
  • StreamingResponse — o commit acontece com o handler concluído e antes de o corpo sair; o download de documento continua passando (tests/test_documents.py), e a sessão não é fechada pela rota, só comitada.
  • Handlers globais de erro — a exceção do commit agora acontece dentro do ciclo da requisição, então cai nos handlers de app/main.py como qualquer outra.

Sobre reproduzir: continua não reproduzindo sob demanda em servidor ocioso — 30 cadeias sequenciais e 48 concorrentes passavam antes e passam depois. A prova da correção é o teste de ordem, não a ausência de falha num cenário que já não falhava. Registrar isso é o honesto: quem olhar amanhã não deve concluir que "rodei e não deu erro" significa corrigido.

Verificação: make test 206 passando (202 antes, +4 desta correção), rodado dentro do container reconstruído — a imagem embute o código, então consolidar não é opcional. Lint: nenhum apontamento novo; em app/api/v1 caiu de 15 para 12, porque o ruff --select I001 --fix que usei para ordenar meus imports arrumou dois pré-existentes de passagem. app/main.py e app/db/session.py ficaram no mesmo número de antes.

Uma falha intermitente conhecida apareceu numa das rodadas — test_partner_agency_search.py::test_busca_alcanca_quem_esta_fora_da_primeira_pagina, a que o CLAUDE.md da raiz já documenta (em SQLite o created_at empata no segundo e o desempate é id desc sobre UUID aleatório). Rodada sozinha, o arquivo passa. Não é desta correção.

apps/web: sem mudança, conferido — o defeito era do contrato da API, e ensinar o cliente a tolerar esconderia erro real.

Suíte e2e executada: 280 passando, zero falhas, 6 skipped (15 min). Era a verificação que faltava, e a que mais importa aqui: a mudança atravessa os 22 routers, e o e2e é o único caminho que exercita HTTP + PostgreSQL de verdade — o conftest do pytest substitui o get_db, então nem a fronteira transacional nova ele enxerga. Nenhuma rota quebrou, e a rodada saiu limpa também nos testes que falharam na rodada da spec 0015 (a corrida da FK entre elas).