UC-NTF-003. Сбой провайдера → повтор доставки
Назначение
Описывает исключительный сценарий: отправка уведомления по внешнему каналу завершается ошибкой провайдера;
уведомление помечается Failed и повторяется фоновым worker-ом с backoff, пока не будет доставлено либо не
исчерпаны попытки. Связан с контекстом notifications.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-NTF-003 |
| Контекст | notifications (см. docs/06-services/notifications/) |
| Тип | исключительный |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | System (NotificationDispatcher / NotificationRetryWorker) |
| Permissions / scope | system-actor; кросс-tenant scan разрешён явно (платформенная отправка) |
| Tenant | обрабатываются уведомления всех tenant-ов и инвесторов (null) |
| Record-level | не применяется |
Предусловие
- Существует
NotificationEntityв статусеPending/Failedпо внешнему каналу (напр.Email), канал включён (ChannelConfig.IsEnabled = true). - Provider-адаптер сконфигурирован (
ProviderKey,MaxAttempts,RetryBackoff).
Триггер
| Поле | Значение |
|---|---|
| Источник | Schedule (cron-цикл 1 мин) |
| Триггерящий элемент | NotificationRetryWorker |
Основной Поток
- Dispatcher отправляет
Pending-уведомление через provider-адаптер; провайдер возвращает retriable-ошибку (5xx/429/timeout). - Уведомление переводится в
Failedс кодом ошибки (Pending → Failed),Attempts = 1,NextAttemptAt = now + backoff(1); изменение сохраняется. - Каждую минуту retry-worker под распределённой блокировкой выбирает батч уведомлений со статусом
Failed, у которыхNextAttemptAt <= nowиAttempts < MaxAttempts(конкурентная выборка без двойной обработки строк). - По каждой записи резолвятся
ChannelConfigи адаптер, выполняется повторная отправка. - Провайдер отвечает успехом → уведомление переходит
Failed → Sent(сохраняетсяProviderMessageId); публикуетсяNotificationDispatchedEvent(Sent)через outbox. - Статус и исходящее событие сохраняются атомарно (по каждой записи); delivery-webhook провайдера позже подтверждает доставку.
- Метрики цикла обновлены (
retry_sent_total); backlogFailedуменьшается. - In-app-копия (если была) уже доставлена realtime независимо от внешнего канала — получатель не остаётся без уведомления.
Альтернативные Потоки
A1. Исчерпаны попытки (терминальный отказ)
- На шаге 4 провайдер снова возвращает ошибку;
AttemptsдостигаетMaxAttempts(INV-NTF-7). - Уведомление остаётся
Failedтерминально; публикуетсяNotificationDispatchedEvent(Failed)+ алерт; запись выбывает из цикла (ручной разбор по runbookterminal-failures).
A2. Non-retriable ошибка (invalid recipient/token)
- Провайдер возвращает
NTF_PROVIDER_INVALID_RECIPIENT/NTF_PUSH_TOKEN_INVALID. - Уведомление сразу помечается терминальным
Failed(без повторов); для push — токен удаляется из store.
A3. Канал выключен / провайдер-заглушка
ChannelConfig.IsEnabled = false(внешний канал не активирован) → worker не делает попытку (skip, метрикаchannel-disabled); уведомление ждёт включения канала.
A4. Падение worker-а между отправкой провайдеру и фиксацией статуса
- Provider уже принял сообщение, но статус ещё не зафиксирован (worker упал/перезапущен). Уведомление
остаётся
Failed/Pendingи попадёт в следующий цикл. - Повторная отправка идемпотентна у провайдера (
clientReference/collapseKey/message-ref =NotificationId, у Telegram — локальный dedup): второе сообщение получателю не уходит; статус приводится кSentпри успешном повторе или delivery-webhook подтверждает доставку. Дубликата у получателя нет.
A5. Гонка retry-worker и прочтения in-app / delivery-webhook
- Пока worker обрабатывает запись, приходит delivery-webhook провайдера или пользователь помечает in-app-копию
прочитанной. Применяется оптимистичная блокировка версии записи: побеждает первая зафиксированная мутация,
проигравшая перечитывает актуальное состояние и не затирает более свежий статус (INV-NTF-5: терминальный
Sentне откатывается).
Постусловие
- Уведомление доставлено (
Sent) либо помечено терминальнымFailedпослеMaxAttempts. - Backlog
Failedи метрики отражают исход;dispatched-событие эмитировано на финальном исходе. Attempts/NextAttemptAt/LastErrorотражают историю попыток; терминальныйFailedвыведен из retry-цикла и ожидает ручного разбора (runbookterminal-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_TRANSIENT | — | 5xx/timeout провайдера | retry по backoff (worker) |
NTF_PROVIDER_RATE_LIMITED | — | 429/лимит | retry по backoff |
NTF_PROVIDER_INVALID_RECIPIENT | — | невалидный адрес | терминальный Failed, без повтора |
NTF_PROVIDER_NOT_IMPLEMENTED | — | канал-заглушка (отложено) | skip до включения канала |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
NotificationStatusChanged | Failed/Sent | обновление статуса/бейджа; метрики |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
NotificationDispatchedEvent | pin.notifications.dispatched.v1 | при Sent/терминальном Failed | (будущая аналитика доставляемости) |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Retriable-ошибка повторяется с backoff до успеха/MaxAttempts | mock-провайдер: N ошибок → N повторов, затем Sent |
| AC2 | После MaxAttempts — терминальный Failed + алерт | превысить порог → Failed, dispatched(Failed) |
| AC3 | Non-retriable — без повторов | invalid recipient → сразу терминальный Failed |
| AC4 | Два инстанса worker-а не дублируют отправку | конкурентная выборка с блокировкой строк + распределённая блокировка → одна попытка на запись |
| AC5 | In-app-копия доставлена независимо от внешнего канала | внешний Failed, in-app Sent |
| AC6 | Падение между отправкой и фиксацией не даёт дубля | provider принял → worker упал → повтор идемпотентен по NotificationId, второго сообщения нет |
| AC7 | failed-webhook переоткрывает и повторяет; DLQ не используется | delivery-webhook failed → Sent→Failed, запись возвращается в цикл; исчерпание попыток → терминальный Failed, не DLQ |
| AC8 | Version-guard не затирает более свежий статус | конкурентные mark-read и retry → побеждает первая мутация, терминальный Sent не откатывается |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Background: notification-retry.md; провайдеры — ../providers/ (email/sms/push/telegram/max/whatsapp/viber заглушки; in-app реальный).
- Domain entity: notification.md (INV-NTF-5/7, переходы
Failed/Sent), channel.md (MaxAttempts,RetryBackoff). - Событие-исход: notification-dispatched.md; контекст — README.