Server-Sent Events (SSE)¶
SSE empurra dados do servidor pro navegador por uma conexão HTTP de longa duração, sem polling. É o caminho mais simples pra "tempo real unidirecional": feed de notificações, barra de progresso, ticker de preço, logs ao vivo.
SSE vs WebSocket vs Web Push
O SDK traz três peças: EventStream (fila async em memória que alimenta
uma conexão), ServerSentEvent (codifica um frame no formato do spec) e
sse_response (embrulha o stream num StreamingResponse com os headers
certos — Cache-Control: no-cache, Connection: keep-alive,
X-Accel-Buffering: no pra desligar o buffer do nginx). No dia a dia você
chama os atalhos EventStream.response(...) / SSEBroker.response(channel),
que já embrulham com o sse_response por baixo; use o sse_response cru só
quando quiser controlar o gerador na mão.
Precisa instalar algo? SSE é nativo
EventStream, ServerSentEvent, sse_response e o SSEBroker em memória
fazem parte do core — não têm extra próprio, já vêm com
tempest-fastapi-sdk (dependem só de starlette, que o FastAPI já traz).
Não existe extra [sse]. Só o bridge Redis (multi-worker) pede o extra
[cache] — uv add "tempest-fastapi-sdk[cache]", que traz o redis. A
auth por cookie/query usa JWTUtils, do extra [auth].
Novidades da v0.91
- Backpressure — a fila do
EventStreamagora é limitada (max_queue, default1000): cliente lento não faz a memória crescer sem limite. A políticaoverflowdecide o que cai. Veja Backpressure. - Lifecycle sem boilerplate —
sse_response(..., on_disconnect=),EventStream.response(...)eSSEBroker.response(channel)fecham o produtor / desregistram o canal sozinhos quando o cliente cai. - Auth por query string pra clientes cookieless (
EventSource). Veja Autenticação.
Um endpoint SSE¶
Crie um EventStream por requisição, publique de um produtor, e ligue o
ciclo de vida do produtor à conexão do cliente — se o cliente cai, o
produtor para.
# src/api/routers/events.py
import asyncio
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from tempest_fastapi_sdk import EventStream
router = APIRouter()
@router.get("/events")
async def events() -> StreamingResponse:
"""Emite 3 frames SSE e fecha o stream."""
stream = EventStream(heartbeat_seconds=15.0)
async def producer() -> None:
try:
for n in range(1, 4):
await stream.publish({"n": n}, event="counter", id=str(n))
await asyncio.sleep(1)
finally:
await stream.close()
task = asyncio.create_task(producer())
# on_disconnect roda quando o cliente cai OU o stream termina:
# é onde você cancela o produtor pra não vazar.
return stream.response(on_disconnect=task.cancel)
Sempre amarre o produtor à conexão
Stream SSE é longo. Se o cliente desconecta no meio, você não quer o
produtor rodando pra sempre. Passe on_disconnect= pro
EventStream.response (ou pro sse_response) — ele roda no finally
do gerador da resposta, o único ponto que dispara na desconexão.
Suba a API e veja os frames crus no terminal — curl -N desliga o buffer
e imprime cada frame assim que chega:
event: counter
id: 1
data: {"n": 1}
event: counter
id: 2
data: {"n": 2}
event: counter
id: 3
data: {"n": 3}
Repare no formato do spec: cada frame é um bloco de linhas campo: valor
(event, id, depois data), e a linha em branco (\n\n) separa um
frame do próximo. Como você passou um dict, o data saiu JSON-serializado
sozinho.
Antes da v0.91: try/finally na mão
Até a v0.90 você embrulhava o stream() num gerador externo só pra
ter o finally. on_disconnect= substitui esse boilerplate:
Anatomia de um evento¶
publish() aceita os quatro campos do spec:
import asyncio
from tempest_fastapi_sdk import EventStream
stream = EventStream()
async def main() -> None:
"""Run this example."""
await stream.publish(
{"orderId": "abc", "status": "paid"}, # data: vira JSON automático
event="order_update", # event: nome do listener no front
id="42", # id: vira Last-Event-ID (resume)
retry=3000, # retry: dica de reconexão (ms)
)
asyncio.run(main())
| Campo | Pro quê serve |
|---|---|
data |
Payload. String/bytes vão crus; qualquer objeto vira JSON. |
event |
Nome do evento — o front escuta com addEventListener(name). Sem isso, cai no "message". |
id |
Vira Last-Event-ID; o navegador reenvia no reconnect pra você retomar. |
retry |
Atraso de reconexão sugerido (ms). |
O tipo do data é o alias exportado SSEData
(str | bytes | Mapping | Sequence | int | float | bool | None) — as formas de
valor JSON, mais str/bytes crus. Pra mandar um objeto que só serializa via
str() (ex.: um UUID solto), embrulhe em str(...) ou num dict antes.
heartbeat_seconds emite um batimento quando o stream fica ocioso, pra
load-balancers não cortarem a conexão. Por default o batimento é um
comentário SSE (: keepalive), invisível ao EventSource: não
dispara listener nenhum, só mantém o socket vivo. None desliga o heartbeat.
Batimento visível: heartbeat_event¶
Comentário mantém o TCP vivo, que é o propósito — mas um cliente que usa o
batimento como prova de conexão viva (pra reconectar, acender indicador
de status, armar watchdog) não tem o que escutar. heartbeat_event troca o
frame:
from tempest_fastapi_sdk import EventStream, ServerSentEvent
stream: EventStream = EventStream(
heartbeat_seconds=15.0,
heartbeat_event=ServerSentEvent(
data={"id": None, "type": "PING", "message": "ping"},
event="ping",
),
)
Agora cada 15s de ociosidade põe no fio um evento ping que o
addEventListener("ping", ...) recebe como qualquer outro.
Precisa carimbar algo que muda a cada batida — timestamp, contador, tamanho de fila? Passe um callable, resolvido por batimento:
from datetime import UTC, datetime
from tempest_fastapi_sdk import EventStream, ServerSentEvent
def ping() -> ServerSentEvent:
"""Build a fresh heartbeat frame carrying the current time.
Returns:
ServerSentEvent: The frame this beat puts on the wire.
"""
return ServerSentEvent(
data={"type": "PING", "at": datetime.now(UTC).isoformat()},
event="ping",
)
stream: EventStream = EventStream(heartbeat_seconds=15.0, heartbeat_event=ping)
As duas escolhas são separadas, e era esse o problema
Antes, heartbeat_seconds decidia as duas coisas: ligar o batimento e
o que ele põe no fio. Quem queria um ping visível tinha que desligar o
heartbeat e reimplementá-lo por fora — um loop periódico publicando em
cada canal aberto, ou seja, infraestrutura genérica de volta pra dentro do
serviço. Agora heartbeat_seconds decide quando e heartbeat_event
decide o quê.
SSEBroker(heartbeat_event=...) repassa para todo stream que abrir, que é
o único jeito de configurar isso num serviço que usa broker: register
constrói o EventStream por dentro, então nem subclasse entra no caminho.
Backpressure (fila limitada)¶
Se um cliente para de ler (aba em segundo plano, rede ruim) mas o
produtor continua publicando, a fila do EventStream cresceria pra
sempre — vazamento de memória clássico. Por isso a fila é limitada:
max_queue (default 1000) e uma política overflow que decide o que
fazer quando enche.
from tempest_fastapi_sdk import EventStream
# Ticker ao vivo: frame velho não vale nada -> descarta o mais antigo.
stream = EventStream(max_queue=500, overflow="drop_oldest")
overflow |
Quando a fila enche | Use quando |
|---|---|---|
"drop_oldest" (default) |
Evicta o evento mais antigo | Dados vivos: ticker, progresso, telemetria — só o estado recente importa. |
"drop_newest" |
Descarta o evento que chegou | O começo do fluxo importa mais que o fim. |
"block" |
Segura o publish() até abrir vaga |
Produtor dedicado a uma conexão e perder evento é inaceitável. |
block pode travar um produtor compartilhado
Com overflow="block", um cliente lento segura o publish(). Se
o mesmo produtor alimenta vários clientes (fan-out), um cliente ruim
trava todos. Só use block quando o produtor serve uma conexão.
O sentinela de close() nunca é descartado nem bloqueado — o stream
sempre encerra. stream.dropped_events conta quantos eventos caíram por
overflow, pra você jogar em métrica/log:
import logging
from tempest_fastapi_sdk import EventStream
logger = logging.getLogger(__name__)
stream = EventStream()
if stream.dropped_events:
logger.warning("SSE lento: %d eventos descartados", stream.dropped_events)
Voltar ao comportamento antigo
max_queue=0 desliga o limite (fila ilimitada, igual pré-0.91). Só
faça isso se você tiver certeza de que o produtor para junto com a
conexão.
Broadcast pra vários clientes (SSEBroker)¶
EventStream é uma conexão. Pra mandar o mesmo evento pra todos os
clientes de um canal (ex.: os devices de um usuário, ou um tópico), o SDK
traz o SSEBroker — registro de streams por canal + fan-out. O canal é
uma string qualquer (id de usuário, slug de sala...).
São três passos: criar o broker uma vez, guardar essa instância no app (pra todo mundo usar a mesma), e injetar nos endpoints.
Passo 1 — criar o broker e a fiação¶
O SSEBroker() é um singleton do processo: todos os canais e streams
abertos vivem dentro dele. Por isso precisa ser um só, compartilhado pela
app inteira — se cada requisição criasse o seu, um publish num broker não
alcançaria os streams presos em outro.
# src/api/dependencies/resources.py
from fastapi import FastAPI, Request
from tempest_fastapi_sdk import SSEBroker
broker = SSEBroker()
def register_broker(app: FastAPI) -> None:
"""Store the singleton broker on app.state (call it in create_app)."""
app.state.broker = broker
def get_broker(request: Request) -> SSEBroker:
"""Return the shared broker from app.state for use in Depends()."""
return request.app.state.broker
O que cada parte faz:
broker = SSEBroker()— cria o broker no import do módulo. Como o módulo é importado uma vez, esse é o mesmo objeto pra qualquer um que o use.register_broker(app)— pendura o broker emapp.state.broker. Você chama isso uma vez ao montar a app (dentro docreate_appou do lifespan):register_broker(app).get_broker(request)— devolverequest.app.state.broker. É o que os endpoints recebem viaDepends(get_broker), garantindo que todos falam com a mesma instância.
Passo 2 — o endpoint de inscrição¶
O cliente abre GET /feed; o endpoint o inscreve no canal do próprio usuário
e devolve o stream. Uma linha resolve tudo: broker.response(canal).
# src/api/routers/feed.py
from uuid import UUID
from fastapi import APIRouter, Depends
from starlette.responses import StreamingResponse
from tempest_fastapi_sdk import SSEBroker
from src.api.dependencies.auth import get_current_user_id
from src.api.dependencies.resources import get_broker
router = APIRouter()
@router.get("/feed")
async def feed(
user_id: UUID = Depends(get_current_user_id),
broker: SSEBroker = Depends(get_broker),
) -> StreamingResponse:
"""Subscribe the caller to their own user channel and stream it."""
return broker.response(str(user_id))
Passo a passo do que acontece a cada GET /feed:
Depends(get_current_user_id)resolve quem é o cliente. O id dele vira o nome do canal — cada usuário tem o seu, isolado dos outros.Depends(get_broker)entrega o broker compartilhado (o do Passo 1).broker.response(str(user_id))faz três coisas numa chamada só:- register — cria um
EventStreamnovo e o inscreve no canaluser_id; - stream — devolve um
StreamingResponsecom os headers de SSE já prontos (o cliente começa a receber); - unregister — liga um
on_disconnectque remove esse stream do canal quando o cliente cai.
- register — cria um
Por que broker.response() não vaza stream
Os três passos (register → stream → unregister) ficam amarrados numa
chamada. O unregister roda no finally do gerador da resposta — o único
ponto que dispara na desconexão — então não tem try/finally pra você
esquecer: cada cliente que sai limpa o próprio registro. E
SSEBroker(max_queue=..., overflow=...) aplica a mesma política de
backpressure (veja acima) a todo stream que o broker abre.
Aviso global: broadcast()¶
publish(canal, ...) resolve um canal para N conexões. O eixo ortogonal —
um evento para N canais — é broadcast():
from tempest_fastapi_sdk import SSEBroker
async def avisar_manutencao(broker: SSEBroker) -> None:
"""Warn every connected client, whichever channel they are on.
Args:
broker (SSEBroker): The process-wide broker.
"""
await broker.broadcast(
{"type": "MAINTENANCE", "message": "Manutenção em 5 minutos."},
event="notice",
)
Mesmos parâmetros do publish, sem o canal. Em processo único, ele varre os
canais locais e entrega em cada um. Com Redis, publica no canal reservado
__broadcast__ — que o PSUBSCRIBE {prefixo}:* de todo worker já
alcança, sem subscrição nova — e o run() de cada worker refana para todos
os streams locais dele.
Um stream inscrito em mais de um canal recebe o evento uma vez: o fan-out é sobre o conjunto de streams, não sobre a lista de canais.
__broadcast__ é nome reservado
register("__broadcast__") e publish("__broadcast__", ...) levantam
ValueError. O nome é como o lado receptor decide entre entregar a um
canal ou a todos; um stream inscrito nele deixaria essa decisão ambígua.
A constante é BROADCAST_CHANNEL, em tempest_fastapi_sdk.sse.
E para métrica de "quantos estão conectados agora", local_channels() lista
os canais com pelo menos um stream aberto neste worker — a contraparte do
local_subscribers(canal), sem precisar tocar em _channels:
from tempest_fastapi_sdk import SSEBroker
def conectados(broker: SSEBroker) -> dict[str, int]:
"""Count the live streams per channel on this worker.
Args:
broker (SSEBroker): The process-wide broker.
Returns:
dict[str, int]: Channel name to local subscriber count.
"""
return {
canal: broker.local_subscribers(canal) for canal in broker.local_channels()
}
Disparando do domínio (controller)¶
O Passo 2 mostrou o lado da inscrição (o cliente entra num canal). Falta o lado da publicação — o broadcast propriamente dito.
O que "broadcast" quer dizer aqui: o broker guarda, por canal, a lista de
streams inscritos. Quando você chama broker.publish("<canal>", ...), ele
percorre todos os streams daquele canal e entrega o mesmo evento a cada um.
Um publish → N clientes. Se o canal é o id de um usuário e ele está com duas
abas abertas, as duas recebem; se não há ninguém inscrito, o publish não faz
nada (não dá erro).
Quem dispara o publish é o controller, quando um evento de negócio
acontece (pedido criado, pagamento confirmado, mensagem nova). Ele orquestra o
service (regra de negócio) e o broker (notificação ao vivo), e usa como canal o
id do usuário que deve ser avisado.
# src/controllers/order.py
from tempest_fastapi_sdk import SSEBroker
from src.schemas import OrderCreateSchema, OrderResponseSchema
from src.services import OrderService
class OrderController:
"""Orchestrates order creation and the live seller notification."""
def __init__(self, order_service: OrderService, broker: SSEBroker) -> None:
"""Wire the order service and the SSE broker.
Args:
order_service (OrderService): Order business logic.
broker (SSEBroker): Fan-out broker for live notifications.
"""
self.order_service = order_service
self.broker = broker
async def create_order(self, data: OrderCreateSchema) -> OrderResponseSchema:
"""Create an order and notify the seller in real time.
Args:
data (OrderCreateSchema): The order creation payload.
Returns:
The created order.
"""
order = await self.order_service.create(data)
await self.broker.publish(
str(order.seller_id), # canal = id de quem recebe a notificação
{"order_id": str(order.id), "total": str(order.total)},
event="order_created",
id=str(order.id),
)
return order
O coração é a chamada broker.publish(...), campo por campo:
- 1º argumento (
str(order.seller_id)) — o canal. Manda o evento pra todos os streams inscritos nesse id (aqui, os devices do vendedor). É por isso que o endpoint de inscrição usa o id do usuário como canal: os dois lados precisam combinar na mesma string. - 2º argumento (o dict) — o payload (
datado SSE). Objeto não-string vira JSON automático (viu em Anatomia). event="order_created"— o nome do evento; o front escuta comaddEventListener("order_created", ...).id=str(order.id)— viraLast-Event-ID, pro cliente retomar do ponto certo se reconectar.
Repare que o controller não toca em EventStream nem em resposta HTTP: ele
só entrega o evento ao broker, que cuida do fan-out. Publicar é fire-and-forget.
O provider monta o controller com o service e o broker injetados — o mesmo
get_broker do Passo 1:
# src/api/dependencies/controllers.py
from fastapi import Depends
from tempest_fastapi_sdk import SSEBroker
from src.api.dependencies.resources import get_broker
from src.api.dependencies.services import get_order_service
from src.controllers import OrderController
from src.services import OrderService
def get_order_controller(
order_service: OrderService = Depends(get_order_service),
broker: SSEBroker = Depends(get_broker),
) -> OrderController:
"""Build an OrderController with its service and the SSE broker."""
return OrderController(order_service, broker)
O router só recebe o controller por Depends e delega — nada de regra de
negócio nem publish solto na rota:
# src/api/routers/orders.py
from fastapi import APIRouter, Depends
from src.api.dependencies.controllers import get_order_controller
from src.controllers import OrderController
from src.schemas import OrderCreateSchema, OrderResponseSchema
router = APIRouter()
@router.post("/orders")
async def create_order(
data: OrderCreateSchema,
controller: OrderController = Depends(get_order_controller),
) -> OrderResponseSchema:
"""Create an order; the seller gets a live SSE notification."""
return await controller.create_order(data)
O comprador que estiver com o GET /feed aberto recebe na hora:
Publicar de fora do request (fila, task, webhook)
broker.publish é só uma coroutine — chame de qualquer lugar que tenha o
broker: um consumer FastStream, uma task TaskIQ, um webhook. Ele só alcança
quem está conectado no momento (o registro do canal some na desconexão);
pra notificação durável, persista no banco e trate o SSE como a camada
ao vivo por cima. Em multi-worker (Redis), o publish chega ao worker onde
o cliente está preso — veja abaixo.
Multi-worker: bridge Redis (pronto, sem código extra)¶
O SSEBroker em memória vive em um worker — com --workers N, um
publish só alcança os clientes presos naquele processo. Dê um client Redis ao
broker e o mesmo broker passa a publicar via Redis PUBLISH; uma task de
fundo (run()) faz PSUBSCRIBE e repassa pros streams locais de cada
worker. Mesmo call site, agora horizontal.
Use o AsyncRedisManager do SDK (extra [cache]) pra abrir a conexão — é o
mesmo client gerenciado (connect/disconnect/health-check) que cache, sessões e
feature flags usam; nada de redis.asyncio cru:
# src/api/app.py
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tempest_fastapi_sdk import SSEBroker
from tempest_fastapi_sdk.cache import AsyncRedisManager
from src.core.settings import settings
cache = AsyncRedisManager(**settings.redis_kwargs())
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Connect Redis, wire the cross-worker broker, run its fan-out loop."""
await cache.connect()
broker = SSEBroker(redis=cache.client, channel_prefix="sse")
app.state.broker = broker
task = asyncio.create_task(broker.run())
try:
yield
finally:
task.cancel()
await broker.aclose()
await cache.disconnect()
app = FastAPI(lifespan=lifespan)
# broker.publish(...) em qualquer worker -> chega em TODOS os workers
O que cada parte faz:
AsyncRedisManager(**settings.redis_kwargs())— o client Redis gerenciado do SDK. Oredis_kwargs()vem do mixinRedisSettings(URL +decode_responses).await cache.connect()primeiro — antes disso,cache.clientlevantaRuntimeError. Por isso o broker é montado dentro do lifespan, não no import do módulo.SSEBroker(redis=cache.client, ...)—cache.clienté oredis.asyncio.Rediscru por baixo; o broker usa ele praPUBLISH/PSUBSCRIBE.app.state.broker = broker— mesma fiação do Passo 1, então oget_brokerdos endpoints continua idêntico.asyncio.create_task(broker.run())— task de fundo que assina o Redis e repassa cada evento pros streams locais do worker. No teardown: cancela a task, fecha o broker, desconecta o Redis.
Comece simples, escale depois
Sem Redis, SSEBroker() já resolve um processo. Quando precisar de
múltiplos workers/hosts, só passe o cache.client do AsyncRedisManager e
suba o run() no lifespan — nenhum endpoint muda, o publish vira
cross-process de graça. O AsyncRedisManager vem do extra [cache]
(uv add "tempest-fastapi-sdk[cache]").
Multi-worker sem Redis: bridge via RabbitMQ¶
O bridge embutido (SSEBroker.run()) é Redis-only — o param redis= não
aceita outro transporte. Se você já roda RabbitMQ e não quer Redis só pra
isso, dá pra montar o fan-out entre workers à mão: cada worker mantém um
SSEBroker() em memória (sem redis=), e um subscriber RabbitMQ em cada
worker repassa o evento pro broker.publish local.
A peça que faz o broadcast funcionar é o exchange fanout: toda mensagem publicada nele é copiada pra todas as filas ligadas. Cada worker declara uma fila exclusiva (some quando o worker cai), então todos recebem cada evento — diferente do default do RabbitMQ (work-queue), em que só um consumidor pegaria a mensagem.
# src/api/app.py
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from faststream.rabbit import ExchangeType, RabbitExchange, RabbitQueue
from tempest_fastapi_sdk import SSEBroker
from tempest_fastapi_sdk.queue import MessageBroker
from src.core.settings import settings
broker = SSEBroker() # em memória, um por worker
mq = MessageBroker.rabbitmq(settings.RABBITMQ_URL) # extra [queue]
sse_exchange = RabbitExchange("sse.fanout", type=ExchangeType.FANOUT)
worker_queue = RabbitQueue("", exclusive=True, auto_delete=True)
@mq.broker.subscriber(worker_queue, sse_exchange) # mq.broker = RabbitBroker do FastStream
async def _fan(evt: dict) -> None:
"""Relay one fanned-out event to this worker's local SSE streams."""
await broker.publish(evt["channel"], evt.get("data", ""), event=evt.get("event"))
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await mq.connect() # abre a conexão e sobe o subscriber
app.state.broker = broker # o mesmo get_broker dos endpoints resolve isso
try:
yield
finally:
await mq.disconnect()
app = FastAPI(lifespan=lifespan)
O que cada parte faz:
SSEBroker()semredis=— fan-out local ao worker (só os streams presos nele).RabbitExchange(..., type=FANOUT)+RabbitQueue("", exclusive=True, auto_delete=True)— o exchange copia pra toda fila ligada; cada worker declara a sua, então todos recebem cada evento.@mq.broker.subscriber(worker_queue, sse_exchange)— omq.brokeré oRabbitBrokerdo FastStream (escape hatch). A fachadaMessageBroker.on(...)não expõe fanout, por isso descemos pro broker cru aqui.await mq.connect()no lifespan sobe o consumidor;mq.disconnect()fecha.
O domínio publica no exchange (não no broker.publish direto) — aí chega em
todos os workers:
import asyncio
from uuid import UUID
from src.db.models import OrderModel, UserModel
from src.queue import mq
user = UserModel(name="Ana", email="ana@example.com")
order = OrderModel(user_id=user.id, total=100)
sse_exchange = "sse.fanout"
user_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
async def main() -> None:
"""Run this example."""
# de qualquer worker/handler:
await mq.broker.publish(
{
"channel": str(user_id),
"event": "order_created",
"data": {"order_id": str(order.id)},
},
exchange=sse_exchange,
)
asyncio.run(main())
Redis continua o caminho mais simples
O RabbitMQ precisa desse exchange fanout + fila exclusiva por worker pra
replicar o que o Redis pub/sub (SSEBroker.run()) faz numa linha. Prefira
RabbitMQ quando ele já é sua infra; senão, o Redis ([cache]) é menos
peça móvel.
Autenticação (cookie ou query string)¶
Aqui mora a pegadinha do SSE: o EventSource nativo do navegador não
deixa você mandar header. Nada de Authorization: Bearer no handshake.
Então existem dois caminhos pra autenticar o stream.
Caminho preferido: cookie de sessão¶
Se o front está na mesma origem da API, use um cookie HttpOnly. O
navegador o manda sozinho quando você abre com withCredentials:
No backend, guarde o stream com o current_user_dependency() do seu
UserAuthService — o mesmo service que o make_auth_router usa (veja o
recipe de Auth flow). Com AUTH_TOKEN_DELIVERY em
"cookie"/"both", ele auto-deriva o cookie_name do login (o header
ainda vence quando presente), então não precisa fiar nada:
# src/api/dependencies/auth.py
from src.services import auth_service # sua instância de UserAuthService
current_user = auth_service.current_user_dependency()
Por que cookie é melhor
O token some da URL: não vaza em log de acesso, histórico do
navegador nem header Referer. HttpOnly ainda tira o token do
alcance de JavaScript (defesa contra XSS). Prefira esse caminho
sempre que a origem for compartilhada.
Alternativa cookieless: token na query string¶
Sem cookie de sessão (front em outra origem, app mobile abrindo um
EventSource cru, ambiente onde withCredentials não rola), passe o
access token na query string. A partir da v0.135 o
current_user_dependency aceita isso via query_param:
# src/api/dependencies/auth.py
from src.services import auth_service # sua instância de UserAuthService
# Ordem de busca: header -> cookie -> query string.
current_user = auth_service.current_user_dependency(query_param="access_token")
# src/api/routers/feed.py
from uuid import UUID
from fastapi import APIRouter, Depends
from starlette.responses import StreamingResponse
from tempest_fastapi_sdk import SSEBroker
from src.db.models import User, UserModel
current_user = UserModel(name="Ana", email="ana@example.com")
def get_broker() -> SSEBroker:
"""Return the process-wide broker built at startup."""
return broker
broker = SSEBroker()
router = APIRouter()
@router.get("/feed")
async def feed(
user: User = Depends(current_user), # resolve o JWT da query string
broker: SSEBroker = Depends(get_broker),
) -> StreamingResponse:
"""Stream autenticado sem cookie — token vem na URL."""
return broker.response(str(user.id))
No front:
// O access token curto entra na URL — nunca o refresh token.
const es = new EventSource(`/api/feed?access_token=${accessToken}`);
Query string vaza — trate o token como descartável
Um token na URL aparece em log de acesso, histórico e no
header Referer. Regras inegociáveis:
- Só access token de vida curta (minutos). Nunca o refresh token.
- Sempre sobre TLS (HTTPS).
- Remova o valor do formato de log do seu proxy/servidor.
- Renove via um endpoint normal (com header/cookie), não pela query.
Sob o capô: make_jwt_user_dependency / make_bearer_token_dependency
O current_user_dependency embrulha o make_jwt_user_dependency (repassando
cookie_name/query_param). Se você não tem um UserAuthService, chame
o factory direto — make_jwt_user_dependency(jwt, load_user, query_param="access_token") —
ou o make_bearer_token_dependency(jwt, query_param="access_token") quando só
quer as claims decodificadas. Mesma ordem header → cookie → query.
Alinhado com o tempest-react-sdk¶
O createEventStream / useEventStream do
tempest-react-sdk
consome esses endpoints com reconnect (backoff exponencial) embutido:
import { createEventStream } from "tempest-react-sdk";
const stream = createEventStream<{ text: string }>("/feed", {
withCredentials: true, // manda cookie de auth no handshake
namedEvents: ["notice"], // <- bate com publish(event="notice")
onMessage: (m) => console.log(m.event, m.data), // data já vem JSON-parseado
});
// stream.close() pra encerrar; stream.reconnect() pra forçar reconexão
Heartbeat: comentário vs evento ping
Por default o heartbeat do EventStream é um comentário — o
EventSource ignora, então o react-sdk nem precisa de heartbeatEvents.
Pra um heartbeat nomeado visível, passe
heartbeat_event=ServerSentEvent(data="ping", event="ping") no
EventStream (ou no SSEBroker, que repassa a todo stream) e configure
heartbeatEvents: ["ping"] no front (default dele). Publicar o ping na
mão vira desnecessário.
Pontos de alinhamento:
publish(event="x")↔namedEvents: ["x"]+onMessage.datanão-string vira JSON ↔ o parser default do react decodifica JSON.id=↔Last-Event-IDreenviado no reconnect (retome de onde parou).- Auth por cookie ↔
withCredentials: true.
Recap¶
EventStream(1 por conexão) +.response()— endpoint SSE com headers prontos (sse_responseé a versão low-level por baixo).- Amarre o produtor à conexão com
on_disconnect=(emEventStream.response,sse_responseoubroker.response) — semtry/finallyna mão. - Fila limitada (
max_queue, default1000) +overflow(drop_oldest/drop_newest/block) evita vazamento por cliente lento;dropped_eventsconta o descarte. publish(data, event=, id=, retry=)cobre os 4 campos do spec;datanão-string vira JSON.- Heartbeat:
heartbeat_secondsdecide quando,heartbeat_eventdecide o quê. Default é comentário (invisível ao EventSource); passe umServerSentEvent(ou callable, resolvido por batimento) pra umpingque o cliente escuta;Noneemheartbeat_secondsdesliga. - Broadcast =
SSEBroker;broker.response(channel)faz register + response + unregister; publique de controllers/tasks/filas combroker.publish(channel, ...); multi-worker = passe um client Redis + subabroker.run()no lifespan. - Aviso global =
broker.broadcast(...): um evento para todos os canais, inclusive nos outros workers (canal reservado__broadcast__, já alcançado peloPSUBSCRIBEexistente).local_channels()lista os canais vivos deste worker. - Auth: cookie (
cookie_name+withCredentials) na mesma origem; query string (query_param, só access token curto sobre TLS) pra clientes cookieless. tempest-react-sdkcreateEventStream/useEventStreamconsome com reconnect;namedEvents↔publish(event=).