mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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>
172 lines
5.0 KiB
TypeScript
172 lines
5.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
BACKOFF_SCHEDULE_MS,
|
|
MAX_CONSECUTIVE_FAILURES,
|
|
classifyHttpStatus,
|
|
classifyTelegramError,
|
|
nextRetryAt,
|
|
shouldAutoDisableAfterTransient,
|
|
} from "@/lib/notifications/retry-policy";
|
|
|
|
describe("retry-policy: BACKOFF_SCHEDULE_MS", () => {
|
|
it("matches the spec [30s, 5min, 30min, 2h]", () => {
|
|
expect(BACKOFF_SCHEDULE_MS).toEqual([
|
|
30 * 1_000,
|
|
5 * 60 * 1_000,
|
|
30 * 60 * 1_000,
|
|
2 * 60 * 60 * 1_000,
|
|
]);
|
|
});
|
|
|
|
it("is frozen so callers can't mutate the schedule at runtime", () => {
|
|
expect(Object.isFrozen(BACKOFF_SCHEDULE_MS)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("retry-policy: nextRetryAt", () => {
|
|
const now = new Date("2026-05-09T12:00:00.000Z");
|
|
|
|
it("returns null for the 0th failure (no failure yet → no cooldown)", () => {
|
|
expect(nextRetryAt(0, now)).toBeNull();
|
|
});
|
|
|
|
it("schedules 30s after the 1st failure", () => {
|
|
const next = nextRetryAt(1, now);
|
|
expect(next?.getTime()).toBe(now.getTime() + 30 * 1_000);
|
|
});
|
|
|
|
it("schedules 5min after the 2nd failure", () => {
|
|
const next = nextRetryAt(2, now);
|
|
expect(next?.getTime()).toBe(now.getTime() + 5 * 60 * 1_000);
|
|
});
|
|
|
|
it("schedules 30min after the 3rd failure", () => {
|
|
const next = nextRetryAt(3, now);
|
|
expect(next?.getTime()).toBe(now.getTime() + 30 * 60 * 1_000);
|
|
});
|
|
|
|
it("schedules 2h after the 4th failure", () => {
|
|
const next = nextRetryAt(4, now);
|
|
expect(next?.getTime()).toBe(now.getTime() + 2 * 60 * 60 * 1_000);
|
|
});
|
|
|
|
it("returns null at the 5th failure (give-up threshold)", () => {
|
|
expect(nextRetryAt(MAX_CONSECUTIVE_FAILURES, now)).toBeNull();
|
|
});
|
|
|
|
it("returns null beyond the threshold (defence-in-depth)", () => {
|
|
expect(nextRetryAt(99, now)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("retry-policy: shouldAutoDisableAfterTransient", () => {
|
|
it("does not give up before the 5th in-a-row failure", () => {
|
|
expect(shouldAutoDisableAfterTransient(1)).toBe(false);
|
|
expect(shouldAutoDisableAfterTransient(4)).toBe(false);
|
|
});
|
|
|
|
it("gives up at exactly the 5th failure", () => {
|
|
expect(shouldAutoDisableAfterTransient(5)).toBe(true);
|
|
});
|
|
|
|
it("stays in give-up mode for any larger value", () => {
|
|
expect(shouldAutoDisableAfterTransient(99)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("retry-policy: classifyTelegramError", () => {
|
|
it("maps 'chat not found' to a hard reject", () => {
|
|
expect(classifyTelegramError("Bad Request: chat not found")).toEqual({
|
|
hardReject: true,
|
|
reason: "telegram_chat_not_found",
|
|
});
|
|
});
|
|
|
|
it("maps 'bot was blocked by the user' to a hard reject", () => {
|
|
expect(
|
|
classifyTelegramError("Forbidden: bot was blocked by the user"),
|
|
).toEqual({ hardReject: true, reason: "telegram_blocked_by_user" });
|
|
});
|
|
|
|
it("maps 'user is deactivated' to a hard reject", () => {
|
|
expect(classifyTelegramError("Forbidden: user is deactivated")).toEqual({
|
|
hardReject: true,
|
|
reason: "telegram_blocked_by_user",
|
|
});
|
|
});
|
|
|
|
it("treats undefined / empty descriptions as soft", () => {
|
|
expect(classifyTelegramError(undefined)).toEqual({
|
|
hardReject: false,
|
|
reason: "telegram_send_failed",
|
|
});
|
|
expect(classifyTelegramError("")).toEqual({
|
|
hardReject: false,
|
|
reason: "telegram_send_failed",
|
|
});
|
|
});
|
|
|
|
it("treats unrecognized descriptions (5xx-style blips) as soft", () => {
|
|
expect(classifyTelegramError("Internal Server Error")).toEqual({
|
|
hardReject: false,
|
|
reason: "telegram_send_failed",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("retry-policy: classifyHttpStatus", () => {
|
|
it("treats web-push 410 as a hard reject", () => {
|
|
expect(classifyHttpStatus(410, "web-push")).toEqual({
|
|
hardReject: true,
|
|
reason: "web_push_410_gone",
|
|
});
|
|
});
|
|
|
|
it("treats web-push 404 as a hard reject (endpoint deleted)", () => {
|
|
expect(classifyHttpStatus(404, "web-push")).toEqual({
|
|
hardReject: true,
|
|
reason: "web_push_404_endpoint",
|
|
});
|
|
});
|
|
|
|
it("treats web-push 429 as a soft reject (so the channel keeps retrying)", () => {
|
|
expect(classifyHttpStatus(429, "web-push")).toEqual({
|
|
hardReject: false,
|
|
reason: "web-push_429",
|
|
});
|
|
});
|
|
|
|
it("treats web-push 500 as a soft reject", () => {
|
|
expect(classifyHttpStatus(500, "web-push")).toEqual({
|
|
hardReject: false,
|
|
reason: "web-push_500",
|
|
});
|
|
});
|
|
|
|
it("treats ntfy 410 as a hard reject (topic invalidated)", () => {
|
|
expect(classifyHttpStatus(410, "ntfy")).toEqual({
|
|
hardReject: true,
|
|
reason: "ntfy_410_gone",
|
|
});
|
|
});
|
|
|
|
it("treats ntfy 404 as soft (could be a wrong topic name, not gone-forever)", () => {
|
|
expect(classifyHttpStatus(404, "ntfy")).toEqual({
|
|
hardReject: false,
|
|
reason: "ntfy_404",
|
|
});
|
|
});
|
|
|
|
it("treats undefined status (network error) as a soft reject", () => {
|
|
expect(classifyHttpStatus(undefined, "ntfy")).toEqual({
|
|
hardReject: false,
|
|
reason: "ntfy_network_error",
|
|
});
|
|
expect(classifyHttpStatus(undefined, "web-push")).toEqual({
|
|
hardReject: false,
|
|
reason: "web-push_network_error",
|
|
});
|
|
});
|
|
});
|