Files
HealthLog/src/lib/notifications/retry-policy.ts
T
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

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

137 lines
5.2 KiB
TypeScript

/**
* Notification dispatcher retry policy (v1.4.15 Phase B3).
*
* Two failure classes:
* - HARD reject (`hardReject: true`) → channel is dead, do NOT retry,
* auto-disable immediately and capture a `disabledReason` so the UI can
* explain *why* the user needs to re-pair.
* - SOFT reject (`hardReject: false`) → upstream temporarily unhappy
* (network timeout, 5xx, 429, generic fetch failure). Retry per the
* backoff schedule and only auto-disable after `MAX_CONSECUTIVE_FAILURES`
* in a row (5).
*
* Hard-reject signal mapping (per spec):
* - Web-Push : statusCode 410 (FCM/Mozilla "subscription gone")
* - Web-Push : statusCode 404 (subscription endpoint deleted) — already
* deletes the row in the sender; treated as hard at the
* channel level only when ALL subs were 410/404 (i.e. the
* channel can no longer succeed).
* - Telegram : Bot API description "chat not found" or
* "Forbidden: bot was blocked by the user"
* - ntfy : statusCode 410 (topic invalidated)
*
* Soft-reject signal mapping:
* - Any HTTP 5xx, HTTP 429, fetch network error / timeout, generic non-OK
* response that doesn't match the hard-reject rules.
*/
export interface SendOutcome {
ok: boolean;
/** True iff the upstream signaled a permanent failure (410, blocked-by-user, …). */
hardReject?: boolean;
/** HTTP status, when the sender went over HTTP. */
statusCode?: number;
/** Stable reason code suitable for `disabled_reason` / `last_failure_reason`. */
reason?: string;
/** Human-readable error fragment for logs (NOT for the disabled_reason column). */
message?: string;
}
/**
* Backoff schedule used to compute `next_retry_at` for soft failures.
* Indexed by `consecutiveFailures` (0-based AFTER the failed attempt).
*
* Spec: [30s, 5min, 30min, 2h, gave-up]. The 5th transient failure flips
* the channel to auto-disabled with reason `give_up_after_5_failures`.
*/
export const BACKOFF_SCHEDULE_MS: readonly number[] = Object.freeze([
30 * 1_000, // 1st failure → wait 30s
5 * 60 * 1_000, // 2nd failure → wait 5min
30 * 60 * 1_000, // 3rd failure → wait 30min
2 * 60 * 60 * 1_000, // 4th failure → wait 2h
]);
export const MAX_CONSECUTIVE_FAILURES = 5;
/**
* Given the new `consecutiveFailures` value (1, 2, …) AFTER incrementing,
* return the wall-clock time when a retry is allowed. Returns null once the
* counter crosses `MAX_CONSECUTIVE_FAILURES` (caller must auto-disable).
*
* The schedule is offset by 1 because `consecutiveFailures=1` means the
* first failure has just happened and we want a 30s cooldown.
*/
export function nextRetryAt(
consecutiveFailures: number,
now: Date = new Date(),
): Date | null {
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) return null;
if (consecutiveFailures < 1) return null;
const idx = Math.min(consecutiveFailures - 1, BACKOFF_SCHEDULE_MS.length - 1);
return new Date(now.getTime() + BACKOFF_SCHEDULE_MS[idx]);
}
/**
* After applying a transient failure, decide whether the channel should be
* auto-disabled (i.e. give-up-after-N-failures fired).
*/
export function shouldAutoDisableAfterTransient(
consecutiveFailures: number,
): boolean {
return consecutiveFailures >= MAX_CONSECUTIVE_FAILURES;
}
/**
* Classify a Telegram Bot API error string into hard/soft.
* The Bot API uses the `description` field (HTTP 200 with `ok=false`), so the
* sender doesn't have a clean status code — this is the canonical source.
*/
export function classifyTelegramError(description: string | undefined): {
hardReject: boolean;
reason: string;
} {
const d = (description ?? "").toLowerCase();
// Permanent: chat does not exist (user deleted the chat or blocked us).
if (d.includes("chat not found")) {
return { hardReject: true, reason: "telegram_chat_not_found" };
}
if (
d.includes("blocked by the user") ||
d.includes("bot was blocked") ||
d.includes("user is deactivated")
) {
return { hardReject: true, reason: "telegram_blocked_by_user" };
}
return { hardReject: false, reason: "telegram_send_failed" };
}
/**
* Convert a numeric HTTP status into our hard/soft classification for
* "regular" HTTP-shaped senders (web-push, ntfy).
*
* Anything that isn't a hard 410/404 (where applicable) and isn't a
* 2xx is treated as soft: a transient blip the dispatcher should
* retry rather than burn the user's channel for.
*/
export function classifyHttpStatus(
status: number | undefined,
service: "web-push" | "ntfy",
): { hardReject: boolean; reason: string } {
if (status === undefined) {
return { hardReject: false, reason: `${service}_network_error` };
}
if (service === "web-push") {
// 410 Gone = subscription is permanently invalid (push provider
// told us so). 404 = endpoint never existed / was unsubscribed.
if (status === 410)
return { hardReject: true, reason: "web_push_410_gone" };
if (status === 404)
return { hardReject: true, reason: "web_push_404_endpoint" };
}
if (service === "ntfy") {
// ntfy.sh treats 410 as "topic deleted, do not retry".
if (status === 410) return { hardReject: true, reason: "ntfy_410_gone" };
}
return { hardReject: false, reason: `${service}_${status}` };
}