Files
HealthLog/prisma/migrations/0028_notification_channel_reliability/migration.sql
T
Marc-André BombeckandClaude Opus 4.7 87a40fd0a4 fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.

Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
  "blocked by the user") → channel auto-disabled, disabled_reason
  captured, audit log notification.channel.auto_disabled written.
  Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
  [30s, 5min, 30min, 2h]. Counter consecutive_failures increments
  per failure; after the 5th in-a-row the channel is auto-disabled
  with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
  channel without calling the sender — defeats the API-quota-burn
  loop a flapping upstream would otherwise create.

Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
  nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
  BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
  columns (recordChannelSuccess, recordChannelHardReject,
  recordChannelTransientFailure, reEnableChannel,
  isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
  give-up. Sender-throws absorbed as transient failures.

Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).

Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:01:45 +02:00

35 lines
1.8 KiB
SQL

-- v1.4.15 Phase B3: notification reliability metadata.
--
-- Until this release the dispatcher fired channels best-effort and the
-- only feedback signal we kept was a Wide-Event warning per failure.
-- That meant a stale Web-Push subscription kept burning bandwidth on
-- every dispatch (a 410 from FCM is a permanent reject — re-trying it
-- never recovers), a Telegram bot whose chat was deleted got the same
-- treatment, and the user never saw the channel's actual health.
--
-- These columns let the dispatcher:
-- 1. Auto-disable a channel on a hard reject (HTTP 410, Telegram
-- "chat not found" or "blocked by the user", ntfy 410), with the
-- reason captured for the Settings UI.
-- 2. Apply exponential backoff [30s, 5min, 30min, 2h] to soft errors
-- (5xx, 429, network timeout) instead of hammering a flapping
-- upstream — and after 5 consecutive transient failures, treat
-- the channel as effectively dead and auto-disable it too.
-- 3. Surface "Active / Auto-disabled / Sending paused" + last
-- success / failure timestamps in Settings → Notifications, with
-- a "Re-enable" button that clears `disabled_reason` +
-- `consecutive_failures` and a "Send test" button.
--
-- All columns are nullable / zero-default so the migration is a no-op
-- for existing rows: a healthy channel reports
-- consecutive_failures=0, last_success_at=null, disabled_reason=null,
-- which renders as "Active (no recent send recorded)" in the UI.
ALTER TABLE "notification_channels"
ADD COLUMN "disabled_reason" TEXT,
ADD COLUMN "consecutive_failures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "last_success_at" TIMESTAMP(3),
ADD COLUMN "last_failure_at" TIMESTAMP(3),
ADD COLUMN "last_failure_reason" TEXT,
ADD COLUMN "next_retry_at" TIMESTAMP(3);