Push (web + mobile in one flow)¶
A product with a site and an app ends up with two notification APIs:
one for the browser, one for the phone, and a caller that has to know
which kind of device it is talking to before sending anything. The
tempest_fastapi_sdk.push module exists to erase that difference.
You say "notify this user". The SDK reads their devices, routes each one
through the right transport, and deletes exactly the ones the provider
disowned — 404/410 on Web Push, UNREGISTERED on FCM. One rule, two
vocabularies.
Installation
- Web: the
[webpush]extra —uv add "tempest-fastapi-sdk[webpush]". - Mobile: the
[firebase]extra —uv add "tempest-fastapi-sdk[firebase]"(the same extra and the same service account as Firebase auth; one credential serves both features). - Web only, mobile only, or both: you install what you use.
import tempest_fastapi_sdk.pushworks with neither.
I already use webpush — does this break it?
No. tempest_fastapi_sdk.webpush still exports WebPushDispatcher,
WebPushSubscriptionService, make_web_push_router and the schemas —
same code, same names. The push module is an addition, not a
replacement: reach for it when you want one path for browsers and
phones. The Web Push recipe still applies to a
browser-only service.
The minimal path¶
1. The device table¶
# 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):
"""One device — browser or phone — that receives notifications."""
__tablename__ = "device_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
One table for both worlds: a browser row keeps p256dh / auth; a
mobile row leaves those NULL and puts the FCM registration token in
token.
2. The transports¶
# 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=...) reuses the Firebase app that ID token
verification already initialized — one service account loaded, two
features.
3. The service and the route¶
# 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]:
"""Build the request-scoped device service."""
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,
)
That gives you POST /api/push/register, POST /api/push/unregister and
GET /api/push/vapid-public-key.
4. Notify¶
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="Order confirmed",
body="Order #1042 is out for delivery.",
tag="order:1042",
data={"url": "/orders/1042"},
),
)
print(result.as_dict())
asyncio.run(main())
A typical result with three devices, one of them dead:
How it works, piece by piece¶
The contract: one method¶
from typing import Protocol
from tempest_fastapi_sdk import PushDevice, PushPayloadSchema
class PushDispatcher(Protocol):
"""Deliver one notification to one device."""
platforms: frozenset[str]
async def send(self, device: PushDevice, payload: PushPayloadSchema) -> None:
"""Deliver the payload to the device."""
...
It is a Protocol, in the same shape UploadStorage uses for storage:
the service depends on the contract, never on a concrete backend. A test
passes a fake transport without inheriting anything; a new provider
(direct APNs, Huawei Push) plugs in without touching the service.
Everything interesting — fan-out, pruning, partial failure — is the service's job, not the transport's. That is what keeps the two halves from drifting apart.
The payload that survives both¶
PushPayloadSchema is the intersection that carries on both providers:
| Field | On the browser | On FCM |
|---|---|---|
title / body |
Notification |
messaging.Notification |
image |
notification icon |
notification.image |
tag |
tag (coalescing) |
android.collapse_key + apns-collapse-id |
data |
notificationclick payload |
data |
data is dict[str, str], and that is not fussiness
FCM rejects non-string values. The schema narrows the type here so the error appears at the edge, with a field name, instead of becoming a provider rejection halfway through a fan-out.
Pruning: one rule, two codes¶
This is the point of the module. When the provider says the device is gone, the row leaves the database — but each provider says it its own way:
| Provider | Signal | Becomes |
|---|---|---|
| Web Push | HTTP 404 / 410 | PushDeviceGoneError → row deleted |
| FCM | UnregisteredError |
PushDeviceGoneError → row deleted |
| FCM | SenderIdMismatchError (token from another project) |
PushDeviceGoneError → row deleted |
| Either | any other failure | PushError → row kept, retried next time |
FCM's InvalidArgumentError does not prune — deliberately
FCM raises InvalidArgumentError both for a bad token and for a
malformed payload, and the exception type does not separate the
two. Treating it as "dead device" would delete the user's entire
fleet the first time a notification body is wrong. The opposite choice
costs one failed attempt per fan-out until the client re-registers —
the cheaper mistake of the two. This deliberately diverges from the
original proposal in issue #157.
One device failing never aborts the others¶
Deliveries run concurrently and independently. The result says what happened to each one:
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="Hi"),
)
print(result.delivered) # how many accepted
print(result.pruned) # deleted (masked)
print(result.failed) # failed, still in the table
print(result.skipped) # no transport configured
asyncio.run(main())
A device token never reaches a log line
A registration token is a credential: whoever holds it can notify that
device. Everything the result exposes — and everything the SDK logs —
goes through mask_push_token, which keeps a 12-character SHA-256
prefix. Same treatment _mask_endpoint already gave the Web Push
endpoint.
skipped is not pruned¶
A service that wired only Web Push and has iOS rows in the database
does not delete them: the device is alive, the wiring is missing. They
come back under skipped, and start being delivered the day an
FCMTransport is added.
Narrowing the 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="Browsers only"),
platforms=[PushPlatform.WEB],
exclude_tokens=["https://push.example/the-device-that-caused-it"],
)
asyncio.run(main())
exclude_tokens is the multi-device sync case: whoever made the change
must not notify themselves. An excluded device is never contacted and
never pruned.
Registration is idempotent by token¶
Registering the same token twice updates the row and refreshes
last_seen_at instead of duplicating. And when the device changes hands
(sign out, sign in on the same handset), the row moves to the new
user — without that, the next notification would reach the previous
account.
Configuration¶
from tempest_fastapi_sdk import BaseAppSettings
from tempest_fastapi_sdk.settings import PushSettings
class Settings(PushSettings, BaseAppSettings):
"""Settings for a service notifying browsers and phones."""
settings = Settings()
print(settings.web_enabled, settings.mobile_enabled, settings.enabled)
PushSettings joins WebPushSettings and FirebaseSettings — and it
exists because of a real trap: both declare enabled, so composing them
by hand makes the MRO silently pick the Web Push one, and a mobile-only
service reads enabled is False with FCM perfectly configured. Here
enabled answers "can this service notify anyone?", and the two halves
stay readable through web_enabled / mobile_enabled.
Testing¶
The contract is a Protocol, so a fake transport is a class with one
method:
from tempest_fastapi_sdk import PushDevice, PushDeviceGoneError, PushPayloadSchema
class FakeTransport:
"""Accepts everything except the tokens you ask it to disown."""
platforms: frozenset[str] = frozenset({"web", "ios", "android"})
def __init__(self, gone: set[str]) -> None:
"""Store the tokens to disown."""
self.gone: set[str] = gone
self.sent: list[str] = []
async def send(self, device: PushDevice, payload: PushPayloadSchema) -> None:
"""Record the delivery or disown the device."""
if device.token in self.gone:
raise PushDeviceGoneError("gone", masked_token=device.masked_token)
self.sent.append(device.token)
The SDK's own suite goes further on the real transports: the FCM test
builds the message with the genuine firebase_admin classes and asserts
the serialized JSON carries token. That pins a measured detail — on
7.5.0 Message.token is deprecated in favour of fid, but the two
are different wire fields ({"token": ...} vs {"fid": ...}), and an
FCM registration token belongs in token. Following the deprecation
literally would send the wrong field.
Recap¶
- One
PushDispatcher(Protocol), two transports:WebPushTransportandFCMTransport. - One table (
BaseDeviceTokenModel) and one service (DeviceService) for browsers and phones. - Unified pruning: 404/410 and
UNREGISTERED/SenderIdMismatchdelete the row; any other failure keeps it and retries. FCM'sInvalidArgumentdoes not prune, so a bad payload cannot wipe the fleet. - One device failing never aborts the others;
PushFanoutResultreports who delivered, who was pruned, who failed and who had no transport. - Tokens never appear in logs or responses — only the masked hash.
tempest_fastapi_sdk.webpushis unchanged;pushis an addition.