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_each sempre retorna, mesmo que algumas mensagens falhem. É preciso checar batch_response.responses item por item
  • Detecção de token expirado vem do BatchResponse. Cada SendResponse tem .success e .exception. Exceções de tipo UnregisteredError, InvalidArgumentError ou NotFoundError indicam 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 PushDevices afetados por UnregisteredError/InvalidArgumentError/NotFoundError e faz bulk_update de is_expired=True numa ú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_each levantou exceção em caso de falha — sempre inspecionar BatchResponse.responses
  • Usar send_each (token-targeted) quando cada destinatário precisa de payload único; usar envio por tópico (messaging.send com topic=) quando todos recebem a mesma mensagem. Hábitos e lembretes sempre têm conteúdo por usuário, então usam send_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_id vindo do cliente para garantir unicidade — o backend é o dono da deduplicação

Referências