Перейти к основному содержимому

UC-NTF-003. Сбой провайдера → повтор доставки

Назначение

Описывает исключительный сценарий: отправка уведомления по внешнему каналу завершается ошибкой провайдера; уведомление помечается Failed и повторяется фоновым worker-ом с backoff, пока не будет доставлено либо не исчерпаны попытки. Связан с контекстом notifications.

Metadata

ПолеЗначение
IDUC-NTF-003
Контекстnotifications (см. docs/06-services/notifications/)
Типисключительный
Статусdraft

Актор

ПолеЗначение
РольSystem (NotificationDispatcher / NotificationRetryWorker)
Permissions / scopesystem-actor; кросс-tenant scan разрешён явно (платформенная отправка)
Tenantобрабатываются уведомления всех tenant-ов и инвесторов (null)
Record-levelне применяется

Предусловие

  • Существует NotificationEntity в статусе Pending/Failed по внешнему каналу (напр. Email), канал включён (ChannelConfig.IsEnabled = true).
  • Provider-адаптер сконфигурирован (ProviderKey, MaxAttempts, RetryBackoff).

Триггер

ПолеЗначение
ИсточникSchedule (cron-цикл 1 мин)
Триггерящий элементNotificationRetryWorker

Основной Поток

  1. Dispatcher отправляет Pending-уведомление через provider-адаптер; провайдер возвращает retriable-ошибку (5xx/429/timeout).
  2. Уведомление переводится в Failed с кодом ошибки (Pending → Failed), Attempts = 1, NextAttemptAt = now + backoff(1); изменение сохраняется.
  3. Каждую минуту retry-worker под распределённой блокировкой выбирает батч уведомлений со статусом Failed, у которых NextAttemptAt <= now и Attempts < MaxAttempts (конкурентная выборка без двойной обработки строк).
  4. По каждой записи резолвятся ChannelConfig и адаптер, выполняется повторная отправка.
  5. Провайдер отвечает успехом → уведомление переходит Failed → Sent (сохраняется ProviderMessageId); публикуется NotificationDispatchedEvent(Sent) через outbox.
  6. Статус и исходящее событие сохраняются атомарно (по каждой записи); delivery-webhook провайдера позже подтверждает доставку.
  7. Метрики цикла обновлены (retry_sent_total); backlog Failed уменьшается.
  8. In-app-копия (если была) уже доставлена realtime независимо от внешнего канала — получатель не остаётся без уведомления.

Альтернативные Потоки

A1. Исчерпаны попытки (терминальный отказ)

  1. На шаге 4 провайдер снова возвращает ошибку; Attempts достигает MaxAttempts (INV-NTF-7).
  2. Уведомление остаётся Failed терминально; публикуется NotificationDispatchedEvent(Failed) + алерт; запись выбывает из цикла (ручной разбор по runbook terminal-failures).

A2. Non-retriable ошибка (invalid recipient/token)

  1. Провайдер возвращает NTF_PROVIDER_INVALID_RECIPIENT/NTF_PUSH_TOKEN_INVALID.
  2. Уведомление сразу помечается терминальным Failed (без повторов); для push — токен удаляется из store.

A3. Канал выключен / провайдер-заглушка

  1. ChannelConfig.IsEnabled = false (внешний канал не активирован) → worker не делает попытку (skip, метрика channel-disabled); уведомление ждёт включения канала.

A4. Падение worker-а между отправкой провайдеру и фиксацией статуса

  1. Provider уже принял сообщение, но статус ещё не зафиксирован (worker упал/перезапущен). Уведомление остаётся Failed/Pending и попадёт в следующий цикл.
  2. Повторная отправка идемпотентна у провайдера (clientReference/collapseKey/message-ref = NotificationId, у Telegram — локальный dedup): второе сообщение получателю не уходит; статус приводится к Sent при успешном повторе или delivery-webhook подтверждает доставку. Дубликата у получателя нет.

A5. Гонка retry-worker и прочтения in-app / delivery-webhook

  1. Пока worker обрабатывает запись, приходит delivery-webhook провайдера или пользователь помечает in-app-копию прочитанной. Применяется оптимистичная блокировка версии записи: побеждает первая зафиксированная мутация, проигравшая перечитывает актуальное состояние и не затирает более свежий статус (INV-NTF-5: терминальный Sent не откатывается).

Постусловие

  • Уведомление доставлено (Sent) либо помечено терминальным Failed после MaxAttempts.
  • Backlog Failed и метрики отражают исход; dispatched-событие эмитировано на финальном исходе.
  • Attempts/NextAttemptAt/LastError отражают историю попыток; терминальный Failed выведен из retry-цикла и ожидает ручного разбора (runbook terminal-failures).

Согласованность и конкурентность

  • Идемпотентность отправки: повтор доставки не создаёт второго сообщения у получателя (провайдерский client-ref = NotificationId; для Telegram — локальный dedup).
  • Конкурентная выборка: два инстанса worker-а не берут одну запись (выборка с блокировкой строк + распределённая блокировка notifications:retry-worker, single owner).
  • Version-guard: dispatcher/retry-worker ↔ mark-read — оптимистичная версия; проигравший перечитывает, терминальный статус не откатывается.
  • DLQ vs терминальный Failed: retry-worker не использует DLQ — исчерпание MaxAttempts даёт терминальный Failed + NotificationDispatchedEvent(Failed) + алерт (DLQ применяется только на входном consumer при нарушении контракта события, см. UC-NTF-002).
  • Окно eventual-consistency: синхронный Sent по ответу провайдера может позже уточняться delivery-webhook; failed-webhook переоткрывает запись в Failed и возвращает её в цикл повторов.

Кросс-сервисные ожидания и гарантии

НаправлениеКонтракт
Ожидает от ChannelConfig (channel.md)IsEnabled, ProviderKey, MaxAttempts, RetryBackoff (Fixed/Exponential)
Ожидает от provider-адаптеровклассификацию ответа: retriable (NTF_PROVIDER_TRANSIENT/NTF_PROVIDER_RATE_LIMITED) vs non-retriable (invalid recipient/token/template/blocked); идемпотентность по NotificationId
Предоставляетфинальный исход доставки — NotificationDispatchedEvent со статусом Sent/Failed (pin.notifications.dispatched.v1, ключ NotificationId, ContractVersion = 1); промежуточные Failed события не порождают
Не зависит отin-app-копии — она доставлена realtime независимо; получатель не остаётся без уведомления даже при терминальном отказе внешнего канала

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
NTF_PROVIDER_TRANSIENT5xx/timeout провайдераretry по backoff (worker)
NTF_PROVIDER_RATE_LIMITED429/лимитretry по backoff
NTF_PROVIDER_INVALID_RECIPIENTневалидный адрестерминальный Failed, без повтора
NTF_PROVIDER_NOT_IMPLEMENTEDканал-заглушка (отложено)skip до включения канала

Доменные События

СобытиеКогда поднимаетсяSubscribers
NotificationStatusChangedFailed/Sentобновление статуса/бейджа; метрики

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
NotificationDispatchedEventpin.notifications.dispatched.v1при Sent/терминальном Failed(будущая аналитика доставляемости)

Acceptance Criteria

#КритерийПроверка
AC1Retriable-ошибка повторяется с backoff до успеха/MaxAttemptsmock-провайдер: N ошибок → N повторов, затем Sent
AC2После MaxAttempts — терминальный Failed + алертпревысить порог → Failed, dispatched(Failed)
AC3Non-retriable — без повторовinvalid recipient → сразу терминальный Failed
AC4Два инстанса worker-а не дублируют отправкуконкурентная выборка с блокировкой строк + распределённая блокировка → одна попытка на запись
AC5In-app-копия доставлена независимо от внешнего каналавнешний Failed, in-app Sent
AC6Падение между отправкой и фиксацией не даёт дубляprovider принял → worker упал → повтор идемпотентен по NotificationId, второго сообщения нет
AC7failed-webhook переоткрывает и повторяет; DLQ не используетсяdelivery-webhook failedSent→Failed, запись возвращается в цикл; исчерпание попыток → терминальный Failed, не DLQ
AC8Version-guard не затирает более свежий статусконкурентные mark-read и retry → побеждает первая мутация, терминальный Sent не откатывается

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы