FCM send_each: envio em lote e tratamento de token expirado
O que aconteceu
Hábitos e lembretes criavam uma linha Notification por device, e o post_save disparava uma task Celery e uma chamada HTTP ao FCM por device. Para N usuários com M devices, cada janela gerava N×M linhas, tasks e requisições. Ao trocar isso por messaging.send_each, alguns comportamentos da SDK precisaram ser entendidos — a começar pelo fato de que falhas parciais não levantam exceção.
Causa raiz
firebase-admin >= 6.0.0 oferece messaging.send_each(messages), que aceita uma lista de até 500 objetos Message distintos (cada um com token, notification e payload de data próprios) e retorna um BatchResponse. Isso substitui as chamadas individuais de messaging.send() quando os payloads diferem por destinatário (ex.: cada hábito de cada usuário tem nome diferente).
Comportamentos que não são óbvios:
- Falhas parciais não levantam exceção.
send_eachsempre retorna, mesmo que algumas mensagens falhem. É preciso checarbatch_response.responsesitem por item - Detecção de token expirado vem do
BatchResponse. CadaSendResponsetem.successe.exception. Exceções de tipoUnregisteredError,InvalidArgumentErrorouNotFoundErrorindicam token FCM inválido/revogado - Limite de 500 mensagens por chamada. Listas maiores precisam ser fatiadas antes
ensure_firebase_initialized()tem que rodar antes do gate de ENABLE. O gate (settings.ENABLE_FIREBASE_NOTIFICATIONS) faz curto-circuito na chamada HTTP, mas o Firebase precisa estar inicializado primeiro. A inicialização vai antes da checagem do gate
Há também uma causa de duplicação no cadastro de devices: Device.osInternalBuildId (usado como device_id no app) retorna o build number do iOS. Quando o usuário atualiza o iOS, tanto o device_id quanto o token FCM rotacionam — o lookup por (device_id, platform, user_id) não encontra nada, um registro novo é criado e o antigo permanece ativo, gerando push duplicada.
Correção
apps/notifications/services/push_batch.py — send_batch(messages):
- Coleta os IDs dos
PushDevicesafetados porUnregisteredError/InvalidArgumentError/NotFoundErrore fazbulk_updatedeis_expired=Truenuma única query - Fatia listas maiores que 500 antes de chamar
send_each - Inicializa o Firebase antes de avaliar o gate
Para a duplicação no registro, a deduplicação passou a ser responsabilidade do backend: ao registrar um device_id novo, os outros registros ativos do mesmo (user, platform) são expirados antes de criar o novo.
Como evitar
- Nunca assumir que
send_eachlevantou exceção em caso de falha — sempre inspecionarBatchResponse.responses - Usar
send_each(token-targeted) quando cada destinatário precisa de payload único; usar envio por tópico (messaging.sendcomtopic=) quando todos recebem a mesma mensagem. Hábitos e lembretes sempre têm conteúdo por usuário, então usamsend_each - Ao expirar um device, também desinscrevê-lo dos tópicos FCM — o envio por tópico ignora
is_expired, então só marcar no banco não resolve duplicação de canal “All” - Não confiar no
device_idvindo do cliente para garantir unicidade — o backend é o dono da deduplicação