Tratamento resiliente de token FCM expirado
TLDR: Marcar
PushDevicescomo expirado quando o FCM rejeitar o token e fazer o app re-registrar automaticamente quando o token FCM rotacionar, evitando envios para tokens mortos e restabelecendo a comunicação de forma transparente.
Contexto
Hoje o backend não tem como saber que um token FCM armazenado deixou de ser válido:
PushDevices(apps/notifications/models.py:9-22) só guardatoken,device_id,platform, etc. — sem flag de expiraçãoNotificationService.send()(apps/notifications/services/notification.py:143-166) capturafirebase_exceptions.FirebaseErrorde forma genérica, gravaerror_messagenaNotificatione segue tentando em todos os envios futurostopic_subscription.pylogaresponse.failure_count, mas nunca limpa o token problemático
No lado do app (onion-app/services/push.ts):
registerDevice()é chamado apenas após login bem-sucedido (stores/loginStore.ts:151)- Não existe listener
onTokenRefreshnem revalidação ao voltar para foreground - Se o FCM rotacionar o token no meio da sessão (reinstalação do SO, limpeza de dados do app, longa inatividade), o backend continua usando o token velho indefinidamente
Resultado: falha silenciosa na entrega de push, sem caminho de recuperação.
Objetivos
- Backend consegue marcar uma linha de
PushDevicescomo expirada quando o FCM rejeita o token - Queries de envio de push ignoram devices expirados
- O app re-registra o device de forma proativa quando o token FCM muda
- Reativação automática: quando o app reenvia o mesmo
device_idcom token novo, a linha volta a ficar ativa
Fora de escopo
— (não registrado na spec original)
Mudanças
onion-backend (Django)
apps/notifications/models.py— adicionar emPushDevices:is_expired: BooleanField(default=False, db_index=True)expired_at: DateTimeField(blank=True, null=True)
- Nova migration em
apps/notifications/migrations/para as duas colunas apps/notifications/services/notification.py— emsend():- Capturar
firebase_admin.messaging.UnregisteredErrorefirebase_admin.exceptions.InvalidArgumentErrorseparadamente - Quando levantado num envio para token individual, setar
is_expired=Trueeexpired_at=timezone.now()noPushDevicescorrespondente - Manter o branch genérico de
FirebaseErrorpara os demais casos
- Capturar
apps/notifications/services/topic_subscription.py— quando o FCM retornar falhas por token comunregistered/invalid-argument, marcar essesPushDevicescomo expirados (caminho em lote)apps/notifications/services/notification.py(resolução de alvo) — filtraris_expired=Falseao selecionar devices/tokens para envioapps/notifications/tasks.py—save_devicereativa a linha no re-registro: quando já existe umPushDevicescom o mesmodevice_id+platform+usere chega umtokennovo, setaris_expired=False,expired_at=Nonee atualizartoken- Testes em
apps/notifications/tests/cobrindo: migration aplica sem erro;UnregisteredErrormarca o device como expirado; devices expirados são excluídos dos envios; re-registro com token novo reativa a linha;PushDevicesFactorycom traits opcionaisis_expired/expired_at
onion-app (Expo / React Native)
services/push.ts:- Helpers
getStoredFCMToken()/setStoredFCMToken()usandoAsyncStoragena chave@onion/fcm-token - Novo
ensureDeviceRegistered(): verifica autenticação, lê o token salvo, chamagetFCMToken()e, se diferirem (ou o salvo for nulo), chamaregisterDevice()e atualiza o storage no sucesso registerDevice()grava o token recém-registrado no storage no sucesso
- Helpers
app/_layout.tsx— registrar três pontos de disparo paraensureDeviceRegistered():- Cold start — chamada única no mount do layout (cobre usuário já logado abrindo o app do zero)
- Volta de background — listener de
AppStateque dispara na transição paraactive(removido no unmount) - Login — manter a chamada existente em
stores/loginStore.ts:151
- Testes em
services/__tests__/push.test.tscobrindo cold start com e sem token salvo, foreground com e sem rotação de token e usuário deslogado
Como verificar
Teste manual end-to-end:
- Caminho de rejeição no backend
- Subir o backend localmente (
make up) e rodar migrations (make migrate) - Autenticar um usuário e fazer
POST /notifications/push/registercom um token deliberadamente inválido e umdevice_idreal - Disparar um envio para esse usuário
- Verificar no banco que a linha correspondente tem
is_expired=Trueeexpired_atpreenchido - Disparar outro envio e confirmar que a linha é filtrada
- Subir o backend localmente (
- Caminho de reativação no backend
- Re-fazer
POST /notifications/push/registercom o mesmodevice_ide umtokennovo - Confirmar que a linha tem
tokenatualizado,is_expired=Falseeexpired_at=None
- Re-fazer
- Re-registro do app em foreground
- Instalar o app, fazer login e confirmar o token registrado no backend
- No build de dev, sobrescrever o valor do AsyncStorage
@onion/fcm-tokene alternar o app entre background e foreground - Confirmar que um novo
POST /notifications/push/registeré disparado
- Cold start com usuário já logado
- Com a sessão persistida, forçar a expiração do
PushDevicesou sobrescrever o@onion/fcm-token - Matar o app completamente e abrir do zero
- Confirmar que
ensureDeviceRegistered()dispara no mount e a linha é reativada/atualizada
- Com a sessão persistida, forçar a expiração do
Documentação
- learnings/notifications_fcm_send_each_batch_expired_tokens.md — quais erros do FCM disparam a expiração de um token