Chat (conversas + mensagens)¶
Um módulo de chat encadeado pronto sobre os primitivos do SDK
(BaseModel / BaseRepository / paginação / SSE). Você herda as tabelas
concretas, monta o router e ganha conversas, mensagens e entrega em tempo
real — sem escrever a camada de dados na mão.
O módulo tempest_fastapi_sdk.chat traz três peças:
- Tabelas abstratas —
BaseConversationModel,BaseConversationParticipantModel,BaseMessageModel,BaseMessageAttachmentModel,BaseMessageReactionModel(+ fábricasmake_*para testes/scripts). ChatService— a lógica de negócio: iniciar conversa, postar, responder, editar, apagar para todos, reagir, encaminhar, marcar como lida e administrar o grupo.make_chat_router— os endpoints HTTP, no mesmo formato demake_auth_router/make_web_push_router.
O que o módulo decide por você
A superfície existe para não deixar cada serviço tomar sozinho as decisões que erram igual em todo lugar:
- recibo de leitura é marca d'água no participante, não uma linha por mensagem por leitor — num grupo de 200 pessoas o caminho óbvio custa 200 linhas por mensagem, e marcar a thread como lida vira um bulk insert;
- uma reação por pessoa, substituída ao reagir de novo — é a
UniqueConstraint(message_id, user_id), e alargá-la para incluir o emoji é o que transforma double-tap em duas reações; - apagar para todos limpa o
bodyde verdade e mantém a linha, para a thread preservar a forma e as respostas continuarem tendo o que citar; - conversa direta é idempotente por par — duas threads entre as mesmas duas pessoas é um estado que o usuário não conserta pela UI;
client_idgerado pelo cliente faz o retry devolver a mensagem que já foi gravada, em vez de postar a segunda.
Sem extra
O módulo usa só o núcleo do SDK. Nada de instalar extras — importe e use.
As tabelas¶
Como as outras tabelas reutilizáveis do SDK, o SDK entrega a linha
abstrata e o seu projeto entrega a concreta (para a FK e o
__tablename__ viverem na metadata da aplicação). Escreva-as à mão em
produção:
from tempest_fastapi_sdk.chat import (
BaseConversationModel,
BaseConversationParticipantModel,
BaseMessageModel,
)
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID
class ConversationModel(BaseConversationModel):
__tablename__ = "conversations"
class ConversationParticipantModel(BaseConversationParticipantModel):
__tablename__ = "conversation_participants"
__table_args__ = (
UniqueConstraint("conversation_id", "user_id", name="uq_participant"),
)
conversation_id: Mapped[UUID] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
class MessageModel(BaseMessageModel):
__tablename__ = "messages"
conversation_id: Mapped[UUID] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
sender_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
As colunas que essas tabelas trazem — kind, client_id,
reply_to_id, forwarded_from_id, forward_score, edited_at,
revoked_at, payload na mensagem; role, joined_at, left_at,
history_from, muted_until, is_pinned, is_archived,
last_read_at, last_read_message_id, last_delivered_at no
participante — são o conjunto mínimo de uma thread entre pessoas. Duas
delas carregam constraint que muda o comportamento, então a classe
escrita à mão precisa declará-las:
from uuid import UUID
from sqlalchemy import ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk.chat import BaseMessageModel, BaseMessageReactionModel
class MessageModel(BaseMessageModel):
"""Uma mensagem postada numa conversa."""
__tablename__ = "messages"
__table_args__ = (
UniqueConstraint("sender_id", "client_id", name="uq_messages_sender_client"),
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
)
conversation_id: Mapped[UUID] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
sender_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
class MessageReactionModel(BaseMessageReactionModel):
"""A reação de uma pessoa a uma mensagem."""
__tablename__ = "message_reactions"
__table_args__ = (
UniqueConstraint("message_id", "user_id", name="uq_reaction_per_user"),
)
message_id: Mapped[UUID] = mapped_column(
ForeignKey("messages.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
A primeira constraint é o que faz o retry devolver a linha existente em vez de postar a segunda; a segunda é o que faz reagir de novo substituir em vez de empilhar.
message_id do anexo é nullable, e isso é o desenho
Upload e post são duas chamadas: um vídeo de 40 MB que falha no
último byte não pode levar a legenda junto, e um post repetido não
pode reenviar o arquivo. A linha do anexo nasce quando os bytes
chegam, e a mensagem que a reivindica pode não existir por mais um
minuto — ou nunca, se o remetente desistir. Declarar a coluna
NOT NULL faz todo upload falhar com erro de integridade.
Atalho para testes
Em testes e scripts, as fábricas montam a classe concreta em runtime:
from tempest_fastapi_sdk.chat import (
make_conversation_model,
make_conversation_participant_model,
make_message_attachment_model,
make_message_model,
make_message_reaction_model,
)
Conversation = make_conversation_model()
Participant = make_conversation_participant_model()
Message = make_message_model()
Attachment = make_message_attachment_model()
Reaction = make_message_reaction_model()
As fábricas já declaram as constraints acima — é a forma mais rápida de ganhar o comportamento certo num teste.
O serviço¶
ChatService recebe três repositórios obrigatórios — e, opcionalmente,
attachments= e reactions= (veja O router) e um SSEBroker
para tempo real. Para o fluxo de texto abaixo, os três bastam:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.chat import ChatService
from src.db.models import ConversationModel, ConversationParticipantModel, MessageModel
def build_chat_service(session: AsyncSession) -> ChatService:
return ChatService(
conversations=BaseRepository(session, model=ConversationModel),
participants=BaseRepository(session, model=ConversationParticipantModel),
messages=BaseRepository(session, model=MessageModel),
)
async def demo(session: AsyncSession, alice: UUID, bob: UUID) -> None:
service = build_chat_service(session)
# O criador entra como participante automaticamente.
conversation = await service.start_conversation(alice, [bob], title="Projeto X")
await service.post_message(conversation.id, alice, "Bora começar?")
await service.post_message(conversation.id, bob, "Bora!")
page = await service.list_messages(conversation.id, page=1, page_size=20)
for message in page["items"]:
print(message.sender_id, message.body)
minhas = await service.list_conversations(alice) # [] quando não há nenhuma
Rodando o demo, os dois print emitem cada mensagem em ordem (mais
antiga primeiro), com o UUID do remetente e o corpo:
list_messages devolve o dicionário de paginação offset padrão do SDK
(items já mapeados para MessageResponseSchema, total, page,
page_size, pages), ordenado do mais antigo para o mais novo.
Responder, e o stub que a citação carrega¶
Responder é a operação mais usada de um chat em grupo. A parte cara não é a FK: é o stub. A citação tem que renderizar sem um segundo round-trip, e reenviar a mensagem-pai inteira (com anexos e reações) sob cada resposta multiplica o payload da página.
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService, MessageCreateSchema
async def responder(
service: ChatService,
conversation_id: UUID,
alice: UUID,
bob: UUID,
) -> None:
"""Posta uma pergunta e responde citando-a."""
parent = await service.post_message(
conversation_id,
alice,
MessageCreateSchema(body="alguém revisa o PR?"),
)
reply = await service.post_message(
conversation_id,
bob,
MessageCreateSchema(body="eu reviso", reply_to_id=parent.id),
)
assert reply.reply_to is not None
print(reply.reply_to.sender_id, reply.reply_to.excerpt, reply.reply_to.revoked)
Apagar a mensagem citada apaga a citação
O campo revoked é o que nenhuma implementação ingênua tem. Sem
ele, o texto apagado vaza pelo quote de quem respondeu — a
mensagem some da thread e continua legível dentro de cada resposta.
revoke_message limpa o body e o stub passa a responder
revoked=True com excerpt vazio.
Idempotência de envio¶
Um POST de mensagem cujo 201 se perde deixa o cliente sem saber se
ela foi. Reenviar às cegas manda duas; não reenviar perde. A saída é uma
client_id gerada pelo cliente antes da requisição:
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService, MessageCreateSchema
async def reenviar(
service: ChatService,
conversation_id: UUID,
alice: UUID,
) -> None:
"""Envia duas vezes com a mesma `client_id` e grava uma só."""
payload = MessageCreateSchema(body="oi", client_id="7f3a-1")
first = await service.post_message(conversation_id, alice, payload)
second = await service.post_message(conversation_id, alice, payload)
assert first.id == second.id # a mesma linha, não uma cópia
É o mesmo problema que o IdempotencyMiddleware resolve para HTTP
genérico — mas aqui a chave é de domínio: ela sobrevive à mensagem, não
à requisição, e é única por (sender_id, client_id), então dois
clientes podem escolher ids independentes.
A unicidade é por remetente, não por conversa
Reaproveitar uma client_id numa outra conversa é recusado com
422 nomeando o campo. Devolver a mensagem encontrada seria pior do
que parece: o cliente receberia 201 com uma linha de outra thread,
enquanto a mensagem que ele realmente enviou nunca foi escrita.
Gere uma id nova por mensagem (um UUID serve).
Recibos: marca d'água, não linha por leitor¶
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService
async def marcar_lida(
service: ChatService,
conversation_id: UUID,
bob: UUID,
last_seen_id: UUID,
) -> None:
"""Move a marca d'água e lê as contagens derivadas dela."""
await service.mark_read(conversation_id, bob) # tudo, agora
await service.mark_read(conversation_id, bob, message_id=last_seen_id)
page = await service.list_messages(conversation_id, with_receipts=True)
print(page["items"][0].receipts)
Uma linha por mensagem por leitor é O(mensagens × participantes): num
grupo de 200 pessoas são 200 linhas por mensagem. A marca d'água são
200 linhas no total, e marcar como lida é um UPDATE. A contagem de
"quem leu" vira um agregado sobre last_read_at >= message.created_at.
O custo é que "lida" passa a ser por instante, não por mensagem — que é
exatamente a granularidade que a interface mostra.
unread_count sai de graça em list_conversations.
Anexos¶
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService, MessageCreateSchema, MessageKind
async def anexar(
service: ChatService,
conversation_id: UUID,
alice: UUID,
storage_key: str,
mime_type: str,
size_bytes: int,
) -> None:
"""Grava o arquivo primeiro e só depois posta a mensagem."""
attachment = await service.add_attachment(
alice,
storage_key=storage_key, # chave, nunca URL
filename="foto.jpg",
mime_type=mime_type,
size_bytes=size_bytes,
)
await service.post_message(
conversation_id,
alice,
MessageCreateSchema(
kind=MessageKind.IMAGE,
body="olha isso", # legenda vive no body
attachment_ids=[attachment.id],
),
)
storage_key é chave, nunca URL: URL é capability com TTL, e
gravá-la congela um acesso na linha. Um id já reivindicado é recusado
com 404 — o mesmo arquivo não entra em duas mensagens.
add_attachment grava a linha com uploader_id preenchido, e o
post_message só reivindica o anexo para uma mensagem do mesmo
usuário. Outra pessoa que descubra o id — num log, numa URL
compartilhada — recebe o mesmo 404 de um id que não existe, então a
resposta não confirma que o arquivo está lá, e o arquivo continua livre
para quem o enviou. Se o seu endpoint de upload grava a linha por conta
própria, preencha uploader_id nela: linha sem uploader continua
reivindicável por qualquer remetente.
uploader_id é coluna nova: migre antes de atualizar
A coluna vive em BaseMessageAttachmentModel, então toda tabela de
anexo concreta a herda e todo SELECT nela passa a nomeá-la.
Atualizar o pacote sem migrar quebra toda rota que monta mensagem,
inclusive mensagem de texto sem anexo, porque a resposta lê os anexos.
Medido com SQLite, rodando o código novo sobre a tabela antiga:
POST /api/chat/conversations/{id}/messages -> 500
GET /api/chat/conversations/{id}/messages -> 500
sqlite3.OperationalError: no such column: message_attachments.uploader_id
A migração é uma coluna nullable e um índice — o que o
alembic revision --autogenerate gera sobre a tabela antiga:
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
op.add_column(
"message_attachments",
sa.Column("uploader_id", sa.Uuid(), nullable=True),
)
op.create_index(
op.f("ix_message_attachments_uploader_id"),
"message_attachments",
["uploader_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_message_attachments_uploader_id"),
table_name="message_attachments",
)
op.drop_column("message_attachments", "uploader_id")
Troque message_attachments pelo __tablename__ da sua tabela. As
linhas que já existiam ficam com uploader_id NULL e seguem a regra
antiga — qualquer remetente as reivindica. É a janela de transição:
arquivo enviado antes do deploy e ainda não postado não fica preso, e
a janela fecha sozinha quando não sobra linha NULL sem mensagem.
Validando o upload de texto
UploadUtils(verify_magic_bytes=True) recusa assinatura
desconhecida, e .txt / .csv não têm assinatura — num chat
isso derruba anexo de texto. Use
require_known_signature=False: recusa só quando a assinatura
contradiz o tipo declarado. E para o checksum do arquivo inteiro
passe hasher=hashlib.sha256(), porque o content_validator vê só
o primeiro chunk.
Apagar para todos ≠ deletar a linha¶
from uuid import UUID
from tempest_fastapi_sdk import UploadUtils
from tempest_fastapi_sdk.chat import ChatService
async def apagar_para_todos(
service: ChatService,
uploads: UploadUtils,
message_id: UUID,
alice: UUID,
) -> None:
"""Revoga a mensagem e apaga os arquivos que ficaram órfãos."""
tombstone, orphaned_keys = await service.revoke_message(message_id, alice)
for key in orphaned_keys:
await uploads.delete(key)
assert tombstone.body == ""
A linha sobrevive (a thread mantém a forma, as respostas mantêm o que
citar) mas o body é limpo de verdade — não escondido atrás de um
flag que a próxima query esquece de filtrar. As chaves de storage saem
no segundo item da tupla: o SDK não é dono do bucket, e chave esquecida
é arquivo que sobrevive à mensagem que o justificava.
A lista traz só chave que nenhuma outra linha de anexo ainda
referencia. Encaminhar aponta a cópia para a mesma storage_key, então
revogar a original de uma mensagem encaminhada devolve [] — a cópia
ainda renderiza o arquivo — e a chave sai na revogação que remove a
última referência. Apagar o que a lista devolve é sempre seguro.
Editar e revogar exigem, além de ser o remetente, que você ainda enxergue a mensagem: participante ativo da conversa. Quem saiu não reescreve nem apaga o que disse lá.
Reação: uma por pessoa¶
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService
async def reagir(service: ChatService, message_id: UUID, bob: UUID) -> None:
"""Reage duas vezes; a segunda substitui a primeira."""
await service.react(message_id, bob, "👍")
updated = await service.react(message_id, bob, "🎉")
print([(r.emoji, r.count) for r in updated.reactions])
Preferências são da sua caixa¶
Fixar, arquivar e silenciar moram no participante. Escritos na conversa, silenciam para todo mundo:
from uuid import UUID
from tempest_fastapi_sdk.chat import ChatService
async def organizar_caixa(
service: ChatService,
conversation_id: UUID,
alice: UUID,
) -> None:
"""Fixa e arquiva a conversa para uma pessoa só."""
await service.set_preferences(conversation_id, alice, is_pinned=True)
await service.set_preferences(conversation_id, alice, is_archived=True)
list_conversations ordena fixadas primeiro, depois por mensagem mais
recente, e esconde as arquivadas salvo include_archived=True.
Grupo de verdade¶
from uuid import UUID
from tempest_fastapi_sdk.chat import (
ChatService,
ConversationKind,
ParticipantRole,
)
async def administrar_grupo(
service: ChatService,
alice: UUID,
bob: UUID,
carol: UUID,
dave: UUID,
) -> None:
"""Cria o grupo e mexe na composição dele."""
group = await service.start_conversation(
alice,
[bob, carol],
kind=ConversationKind.GROUP,
title="Projeto X",
)
await service.add_participants(group.id, alice, [dave]) # history_from = agora
await service.set_role(group.id, alice, bob, ParticipantRole.ADMIN)
await service.remove_participant(group.id, alice, dave)
await service.leave(group.id, bob)
Quem entra depois não herda o backlog por padrão (history_from é
carimbado na entrada); passe share_history=True para entregar o
histórico. Quem sai mantém a linha com left_at — senão mensagens
antigas perdem o nome do remetente. O dono não pode ser removido.
kind é inferido quando você não diz
Sem kind, até duas pessoas vira DIRECT e mais que isso vira
GROUP — que é o que quem nunca ouviu falar da distinção quer
dizer. Pedir DIRECT explicitamente com outra contagem é
recusado com 422.
Mensagem de sistema¶
"Ana criou o grupo", "Bruno saiu". Cada mudança de composição posta uma
mensagem kind=system com payload estruturado:
O cliente renderiza do payload — assim o texto é localizado e os
nomes viram link. O body que o servidor escreve é fallback para push e
busca, nunca a representação primária.
Custo de uma página¶
list_messages resolve a página inteira com um número fixo de
queries — anexos, reações e as mensagens citadas saem em um IN cada,
não um por linha. Medido numa página de 20 mensagens: resolvendo linha a
linha eram 42 statements; em lote são 4, e continuam 4 quando a página
cresce. Encaminhar copia a linha do anexo (mesma storage_key), nunca
os bytes.
O router¶
make_chat_router recebe como resolver a sessão, o serviço e o
usuário autenticado — igual aos outros routers do SDK:
from collections.abc import AsyncIterator
from uuid import UUID
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk.chat import make_chat_router
from src.api.dependencies.resources import db
from src.api.dependencies.services import build_chat_service
sessionmaker = db.get_session_context
async def get_session() -> AsyncIterator[AsyncSession]:
async with sessionmaker() as session:
yield session
def current_user_id() -> UUID:
... # sua dependência de auth (JWT/sessão) resolvendo o UUID do usuário
app = FastAPI()
app.include_router(
make_chat_router(
service_factory=build_chat_service,
session_factory=get_session,
current_user_id=current_user_id,
)
)
Endpoints montados (todos exigem autenticação):
| Método | Rota | Faz |
|---|---|---|
POST |
/api/chat/conversations |
Inicia conversa (criador vira participante) |
GET |
/api/chat/conversations |
Lista as conversas do usuário, fixadas primeiro |
GET |
/api/chat/conversations/{id} |
Uma conversa com os participantes |
PATCH |
/api/chat/conversations/{id} |
Edita título ou descrição do grupo |
PUT |
/api/chat/conversations/{id}/preferences |
Fixa, arquiva ou silencia só para quem chama |
POST |
/api/chat/conversations/{id}/participants |
Adiciona membros ao grupo |
DELETE |
/api/chat/conversations/{id}/participants/{participant_id} |
Remove um membro do grupo |
POST |
/api/chat/conversations/{id}/leave |
Sai da conversa |
POST |
/api/chat/conversations/{id}/read |
Move a marca de leitura de quem chama |
POST |
/api/chat/conversations/{id}/messages |
Posta mensagem (só participante) |
GET |
/api/chat/conversations/{id}/messages |
Pagina o histórico (só participante) |
PATCH |
/api/chat/messages/{id} |
Edita uma mensagem sua |
DELETE |
/api/chat/messages/{id} |
Apaga para todos, deixando a lápide |
PUT |
/api/chat/messages/{id}/reaction |
Reage, substituindo a reação anterior |
DELETE |
/api/chat/messages/{id}/reaction |
Remove a sua reação |
POST |
/api/chat/messages/{id}/forward |
Encaminha para outras conversas |
GET |
/api/chat/conversations/{id}/stream |
SSE de novas mensagens (só participante) |
Reação e anexo precisam dos dois repositórios opcionais
attachments= e reactions= são opcionais no ChatService. Sem
reactions=, o PUT /messages/{id}/reaction responde 422 com
"this chat service was built without a reaction repository"; sem
attachments=, postar com attachment_ids responde 422 também. Por
isso a fábrica de Tempo real via SSE passa os
cinco repositórios.
Guarda de participante
Postar, ler e assinar exigem que o usuário autenticado seja
participante da conversa; caso contrário o router responde 403.
As rotas que recebem só o id de uma mensagem (PATCH/DELETE
/messages/{id}, PUT/DELETE /messages/{id}/reaction e
POST /messages/{id}/forward) conferem a conversa da mensagem: o
usuário precisa ser participante ativo dela, e a mensagem não pode ser
anterior ao history_from dele. Fora disso a resposta é o mesmo 404
de uma mensagem que não existe — corpo idêntico —, para o id não virar
oráculo de existência. Encaminhar é leitura da original: quem não a
enxerga não a copia para outra conversa. E responder citando uma
mensagem anterior ao seu history_from também é 404, porque o stub da
citação carregaria o trecho do backlog que você não recebeu.
Limites de entrada¶
O histórico aceita page >= 1 e 1 <= page_size <= max_page_size
(MESSAGES_PAGE_SIZE_MAX, 100, por padrão); fora disso é 422. Antes,
page_size=0 era um 500 (divisão por zero) e page_size=-1 virava
LIMIT -1, que o SQLite lê como "sem limite" — 25 de 25 mensagens
numa página. ChatService.list_messages recusa os mesmos valores com
ValidationException quando chamado direto.
Os schemas limitam o tamanho do que um request escreve, e passar do teto
é 422:
| Campo | Teto | Constante |
|---|---|---|
body (postar e editar) |
65 536 caracteres | MESSAGE_BODY_MAX_LENGTH |
attachment_ids |
32 ids | MESSAGE_ATTACHMENTS_MAX |
conversation_ids (encaminhar) |
20 conversas | FORWARD_TARGETS_MAX |
participant_ids (iniciar, adicionar) |
256 ids | PARTICIPANT_IDS_MAX |
title / description |
255 / 512 caracteres | o tamanho da coluna |
Passe max_page_size= para make_chat_router para mudar o teto da
página; os outros vivem nos schemas.
Tempo real via SSE¶
Injete um SSEBroker no serviço e cada mensagem postada também é
publicada no canal da conversa (str(conversation_id)), reaproveitando o
fan-out SSE que o SDK já tem. Sem broker, o endpoint /stream responde
404.
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.chat import ChatService
from tempest_fastapi_sdk.sse import SSEBroker
from src.db.models import (
ConversationModel,
ConversationParticipantModel,
MessageAttachmentModel,
MessageModel,
MessageReactionModel,
)
broker = SSEBroker() # single-process; passe redis=<client> para multi-worker
def build_chat_service(session: AsyncSession) -> ChatService:
return ChatService(
conversations=BaseRepository(session, model=ConversationModel),
participants=BaseRepository(session, model=ConversationParticipantModel),
messages=BaseRepository(session, model=MessageModel),
attachments=BaseRepository(session, model=MessageAttachmentModel),
reactions=BaseRepository(session, model=MessageReactionModel),
broker=broker,
)
O cliente assina com um EventSource apontando para
/api/chat/conversations/{id}/stream e recebe eventos message cujo
data é o JSON de MessageResponseSchema:
event: message
data: {"id": "7d3c5f8a-...", "conversation_id": "1a2b3c4d-...", "sender_id": "7d3c5f8a-...", "body": "Bora!", "created_at": "2026-07-18T14:32:07Z"}
O stream não segura sessão de banco¶
A checagem de participante do /stream recebe a sessão do
session_factory como Depends(..., scope="function"), que o FastAPI
fecha quando o endpoint retorna, antes do primeiro byte. Uma sessão
com o escopo padrão ficaria aberta — conexão emprestada do pool,
transação começada — enquanto o cliente continuasse conectado, e algumas
centenas de abas ociosas esgotariam o pool. Isso vale para o que o
router controla: se a sua dependência current_user_id abre sessão, ela
continua aberta pela duração do stream.
O stream acaba quando a participação acaba¶
leave e remove_participant publicam, depois do aviso de sistema, um
evento participant.removed (PARTICIPANT_REMOVED_EVENT) cujo data é
o ParticipantResponseSchema de quem saiu. O stream de quem ele nomeia
entrega esse evento e fecha; o EventSource tenta reconectar e leva
403. Os outros participantes recebem o evento e seguem conectados.
Para saída que não passa pelo ChatService — um script, um UPDATE
direto —, o stream relê a participação numa sessão curta a cada
membership_recheck_seconds (30.0 por padrão), contados nos frames que
ele entrega, heartbeat incluído. None desliga a releitura e deixa só o
evento.
get_session com sub-dependências e a releitura
A releitura roda com a resposta já saindo, fora da resolução de
dependências do FastAPI, então ela abre a sessão chamando
stream_session_factory(). Por padrão esse é o próprio
session_factory, desde que ele não receba parâmetro. Se o seu
get_session recebe request: Request ou Depends(get_database),
passe um factory sem argumentos para a releitura
(stream_session_factory=db.session_dependency) — sem ele, com a
releitura ligada, o make_chat_router recusa a configuração com
ValueError na montagem, e não no primeiro stream.
Veja a receita de Server-Sent Events » para o lado do cliente e a ponte Redis multi-worker.
Recapitulando¶
- Herde as cinco tabelas abstratas e aponte as FKs para o seu
UserModel— declarando as duas constraints que mudam comportamento ((sender_id, client_id)e(message_id, user_id)). ChatServicecobre iniciar, postar, responder, editar, apagar para todos, reagir, encaminhar, marcar como lida e administrar o grupo; devolve schemas, não ORM.- Recibo é marca d'água no participante; reação é uma por pessoa; revogar limpa o body e devolve as chaves de storage para você apagar.
client_idtorna o reenvio idempotente; conversa direta é idempotente por par.make_chat_routermonta os endpoints com guarda de participante — que considera quem saiu como não-participante. Rota que recebe só o id da mensagem confere a conversa dela e responde404igual ao de mensagem inexistente.- Passe um
SSEBrokerpara ganhar entrega em tempo real de graça — o stream não segura sessão de banco e fecha quando a participação acaba.