Push (web + mobile num fluxo só)¶
Um produto que tem site e app acaba com duas APIs de notificação: uma
para o navegador, outra para o celular, e um caller que precisa saber de
que tipo é cada aparelho antes de mandar qualquer coisa. O módulo
tempest_fastapi_sdk.push existe para apagar essa diferença.
Você diz "notifica esse usuário". O SDK lê os aparelhos dele, manda cada
um pelo transporte certo, e apaga exatamente os que o provedor
renegou — 404/410 no Web Push, UNREGISTERED no FCM. Uma regra, dois
vocabulários.
Instalação
- Web: extra
[webpush]—uv add "tempest-fastapi-sdk[webpush]". - Mobile: extra
[firebase]—uv add "tempest-fastapi-sdk[firebase]"(o mesmo extra e a mesma service account da Auth Firebase; uma credencial serve às duas features). - Só web, só mobile, ou os dois: você instala o que usa.
import tempest_fastapi_sdk.pushfunciona sem nenhum dos dois.
Já uso webpush, quebra?
Não. tempest_fastapi_sdk.webpush continua exportando
WebPushDispatcher, WebPushSubscriptionService,
make_web_push_router e os schemas — mesmo código, mesmos nomes. O
módulo push é adição, não substituição: use quando quiser um
caminho só para navegador e celular. A receita de
Web Push continua valendo para quem só tem navegador.
O caminho mínimo¶
1. A tabela de aparelhos¶
# src/db/models/device.py
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID
from tempest_fastapi_sdk import BaseDeviceTokenModel
class DeviceModel(BaseDeviceTokenModel):
"""Um aparelho — navegador ou celular — que recebe notificação."""
__tablename__ = "device_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
Uma tabela para os dois mundos: linha de navegador guarda p256dh /
auth; linha de celular deixa esses campos NULL e põe o token de
registro do FCM em token.
2. Os transportes¶
# src/api/dependencies/push.py
from tempest_fastapi_sdk import (
FCMTransport,
FirebaseAuth,
WebPushDispatcher,
WebPushTransport,
)
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
transports = [
WebPushTransport(WebPushDispatcher(**settings.webpush_kwargs())),
FCMTransport(auth=firebase),
]
FCMTransport(auth=...) reaproveita o app Firebase que a verificação de
ID token já inicializou — uma service account carregada, duas features.
3. O serviço e a rota¶
# src/api/routers/push.py
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository, DeviceService, make_push_router
from src.api.dependencies.auth import current_user_id
from src.api.dependencies.push import transports
from src.core.settings import settings
from src.db.models import DeviceModel
from src.db.session import get_session
def device_service(session: AsyncSession) -> DeviceService[Any]:
"""Constrói o serviço de aparelhos para a requisição."""
repository: BaseRepository[Any] = BaseRepository(session, model=DeviceModel)
return DeviceService(repository, transports)
router = make_push_router(
service_factory=device_service,
session_factory=get_session,
current_user_id=current_user_id,
vapid_public_key=settings.VAPID_PUBLIC_KEY,
)
Pronto: POST /api/push/register, POST /api/push/unregister e
GET /api/push/vapid-public-key.
4. Notificar¶
import asyncio
from typing import Any
from uuid import UUID
from tempest_fastapi_sdk import BaseRepository, DeviceService, PushPayloadSchema
from src.api.dependencies.push import transports
from src.db.models import DeviceModel
from src.db.session import get_session
async def main() -> None:
"""Run this example."""
async for session in get_session():
repository: BaseRepository[Any] = BaseRepository(session, model=DeviceModel)
service: DeviceService[Any] = DeviceService(repository, transports)
result = await service.notify_user(
UUID("2f1b0f1e-0f4a-4e35-9a5f-2c8a2f9a1234"),
PushPayloadSchema(
title="Pedido confirmado",
body="Seu pedido #1042 saiu para entrega.",
tag="order:1042",
data={"url": "/orders/1042"},
),
)
print(result.as_dict())
asyncio.run(main())
Saída típica com três aparelhos, um deles morto:
Como funciona, peça por peça¶
O contrato: um método¶
from typing import Protocol
from tempest_fastapi_sdk import PushDevice, PushPayloadSchema
class PushDispatcher(Protocol):
"""Entrega uma notificação para um aparelho."""
platforms: frozenset[str]
async def send(self, device: PushDevice, payload: PushPayloadSchema) -> None:
"""Entrega o payload no aparelho."""
...
É um Protocol, no mesmo formato que UploadStorage usa para storage:
o serviço depende do contrato, nunca de um backend concreto. Um teste
passa um transporte falso sem herdar de nada; um provedor novo (APNs
direto, Huawei Push) entra sem tocar no serviço.
O interessante — fan-out, poda, falha parcial — é trabalho do serviço, não do transporte. É o que impede as duas metades de divergirem.
O payload que atravessa os dois¶
PushPayloadSchema é a interseção que sobrevive nos dois provedores:
| Campo | No navegador | No FCM |
|---|---|---|
title / body |
Notification |
messaging.Notification |
image |
icon da notificação |
notification.image |
tag |
tag (coalescência) |
android.collapse_key + apns-collapse-id |
data |
payload do notificationclick |
data |
data é dict[str, str], e isso não é capricho
O FCM recusa valores que não sejam string. O schema estreita o tipo aqui para o erro aparecer na borda, com nome de campo, em vez de virar rejeição do provedor no meio de um fan-out.
A poda: uma regra, dois códigos¶
Esse é o ponto do módulo. Quando o provedor diz que o aparelho não existe mais, a linha sai do banco — mas cada provedor fala isso do seu jeito:
| Provedor | Sinal | Vira |
|---|---|---|
| Web Push | HTTP 404 / 410 | PushDeviceGoneError → linha apagada |
| FCM | UnregisteredError |
PushDeviceGoneError → linha apagada |
| FCM | SenderIdMismatchError (token de outro projeto) |
PushDeviceGoneError → linha apagada |
| Qualquer um | qualquer outra falha | PushError → linha mantida, tenta de novo |
InvalidArgumentError do FCM não poda — de propósito
O FCM levanta InvalidArgumentError tanto para token ruim quanto
para payload malformado, e o tipo da exceção não separa os dois.
Tratar isso como "aparelho morto" apagaria a frota inteira do usuário
na primeira vez que um corpo de notificação sair errado. O custo da
escolha oposta é uma tentativa falha por fan-out até o cliente
re-registrar — o erro mais barato dos dois. Isso diverge da proposta
original da issue #157, de propósito.
Falha de um aparelho não aborta os outros¶
As entregas rodam concorrentes e independentes. O resultado diz o que aconteceu com cada uma:
import asyncio
from typing import Any
from uuid import UUID
from tempest_fastapi_sdk import (
BaseRepository,
DeviceService,
PushFanoutResult,
PushPayloadSchema,
)
from src.api.dependencies.push import transports
from src.db.models import DeviceModel
from src.db.session import get_session
async def main() -> None:
"""Run this example."""
async for session in get_session():
repository: BaseRepository[Any] = BaseRepository(session, model=DeviceModel)
service: DeviceService[Any] = DeviceService(repository, transports)
result: PushFanoutResult = await service.notify_user(
UUID("2f1b0f1e-0f4a-4e35-9a5f-2c8a2f9a1234"),
PushPayloadSchema(title="Oi"),
)
print(result.delivered) # quantos aceitaram
print(result.pruned) # apagados (mascarados)
print(result.failed) # falharam e continuam na tabela
print(result.skipped) # sem transporte configurado
asyncio.run(main())
Token de aparelho nunca aparece em log
Um token de registro é credencial: quem tem, notifica aquele
aparelho. Tudo que o resultado expõe — e tudo que o SDK loga — passa
por mask_push_token, que guarda 12 caracteres de SHA-256. É o mesmo
tratamento que _mask_endpoint já dava ao endpoint do Web Push.
skipped não é pruned¶
Um serviço que configurou só Web Push e tem linhas de iOS no banco
não apaga essas linhas: o aparelho está vivo, o que falta é fiação. Elas
saem em skipped, e passam a ser entregues no dia em que o
FCMTransport for adicionado.
Recortar o fan-out¶
import asyncio
from typing import Any
from uuid import UUID
from tempest_fastapi_sdk import (
BaseRepository,
DeviceService,
PushPayloadSchema,
PushPlatform,
)
from src.api.dependencies.push import transports
from src.db.models import DeviceModel
from src.db.session import get_session
async def main() -> None:
"""Run this example."""
async for session in get_session():
repository: BaseRepository[Any] = BaseRepository(session, model=DeviceModel)
service: DeviceService[Any] = DeviceService(repository, transports)
await service.notify_user(
UUID("2f1b0f1e-0f4a-4e35-9a5f-2c8a2f9a1234"),
PushPayloadSchema(title="Só no navegador"),
platforms=[PushPlatform.WEB],
exclude_tokens=["https://push.example/o-aparelho-que-causou-o-evento"],
)
asyncio.run(main())
exclude_tokens é o caso de sincronização multi-aparelho: quem fez a
mudança não deve notificar a si mesmo. Aparelho excluído não é contatado
nem podado.
Registro é idempotente por token¶
Registrar duas vezes o mesmo token atualiza a linha e renova
last_seen_at, em vez de duplicar. E se o aparelho trocar de dono
(logout + login no mesmo celular), a linha muda de usuário — sem
isso, a próxima notificação iria para a conta anterior.
Configuração¶
from tempest_fastapi_sdk import BaseAppSettings
from tempest_fastapi_sdk.settings import PushSettings
class Settings(PushSettings, BaseAppSettings):
"""Settings de um serviço que notifica navegador e celular."""
settings = Settings()
print(settings.web_enabled, settings.mobile_enabled, settings.enabled)
PushSettings junta WebPushSettings e FirebaseSettings — e existe
por causa de uma armadilha real: os dois declaram enabled, então
compondo na mão o MRO escolhe silenciosamente o do Web Push, e um serviço
só-mobile lê enabled is False com o FCM perfeitamente configurado. Aqui
enabled responde "dá para notificar alguém?", e as duas metades ficam
legíveis em web_enabled / mobile_enabled.
Testando¶
O contrato é um Protocol, então um transporte falso é uma classe com um
método:
from tempest_fastapi_sdk import PushDevice, PushDeviceGoneError, PushPayloadSchema
class FakeTransport:
"""Aceita tudo, menos os tokens que você mandar renegar."""
platforms: frozenset[str] = frozenset({"web", "ios", "android"})
def __init__(self, gone: set[str]) -> None:
"""Guarda os tokens que devem ser renegados."""
self.gone: set[str] = gone
self.sent: list[str] = []
async def send(self, device: PushDevice, payload: PushPayloadSchema) -> None:
"""Registra a entrega ou renega o aparelho."""
if device.token in self.gone:
raise PushDeviceGoneError("gone", masked_token=device.masked_token)
self.sent.append(device.token)
A suíte do SDK vai além nos transportes de verdade: o teste do FCM
constrói a mensagem com as classes reais do firebase_admin e afirma que
o JSON serializado carrega token. Isso pega um detalhe medido — na
7.5.0 o Message.token está deprecated em favor de fid, mas os
dois são campos de wire diferentes ({"token": ...} vs
{"fid": ...}), e um token de registro do FCM pertence a token. Seguir
a depreciação ao pé da letra mandaria o campo errado.
Recap¶
- Um
PushDispatcher(Protocol), dois transportes:WebPushTransporteFCMTransport. - Uma tabela (
BaseDeviceTokenModel) e um serviço (DeviceService) para navegador e celular. - Poda unificada: 404/410 e
UNREGISTERED/SenderIdMismatchapagam a linha; qualquer outra falha mantém e tenta de novo.InvalidArgumentdo FCM não poda, para um payload ruim não apagar a frota. - Falha de um aparelho não aborta os outros; o
PushFanoutResultdiz quem entregou, quem foi podado, quem falhou e quem ficou sem transporte. - Token nunca aparece em log nem em resposta — só o hash mascarado.
tempest_fastapi_sdk.webpushcontinua igual;pushé adição.