mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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>
137 lines
5.2 KiB
TypeScript
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}` };
|
|
}
|