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:
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):
cd apps/e2e && npm test— a suíte completa, 3 browsers, ~15 workers em paralelo.- O
setupDraftCreditAnalysisdecredit-analyses-mobile.spec.tscria imobiliária → inquilino → imóvel → análise, sem pausa entre as chamadas. - Em uma das execuções o
POST /credit-analysesdevolveu 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
201com 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 é:
- handler termina e monta o
201com o id; - resposta entregue ao cliente;
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
COMMITtermine 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
201nas 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 (
setupDraftCreditAnalysise osetupAnalysisda 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
IntegrityErrorno 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.pytem 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 testpassando (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 testemake lintsem apontamento novo nos arquivos tocados - [x]
apps/websem 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 emrequest.statepara o commit alcançá-la. É a recomendação. commitexplí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:
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:
- A classe não podia ficar num lugar só. O
include_routerdo FastAPI recria cada rota preservando a classe do router filho (route_class_override=type(route)), então declararroute_classno router de/api/v1não alcançaria nada de dentro. Foram os 22 routers maisapp.router.route_classnocreate_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 comAPIRouter()puro falha a suíte. Foi esse teste que encontrou as duas rotas de/healthfora. - O segundo defeito era menos grave do que eu escrevi — e o teste me corrigiu. Eu supunha que uma falha no
COMMITproduzia201e nada gravado. Na prática oServerErrorMiddlewaredo 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 deapp/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.pycomo 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).