mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
A self-hoster's Gotify or ntfy on a private network was refused at dial time with no operator override: both senders pinned the public resolver, and the two grants that exist for other surfaces were never read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma- separated list of exact origins read from the server environment. A listed origin runs under the operator-approved pin: still resolved and pinned inside the connector, redirects forbidden, loopback and metadata answers refused. An origin not listed keeps the public pin it always had. A malformed entry is logged once and grants nothing, and the valid entries beside it keep working, so a typo in one relay's origin cannot take every public webhook down with it. There is no `=true`, CIDR, wildcard or suffix form. One function, evaluateNotificationTarget, gives the verdict for both senders, so the test button and the scheduled reminder cannot disagree. A refusal now carries its own reason (`*_private_origin_refused`) and an errorCode instead of collapsing into a network error, and a send that used the grant marks the wide event with `notification.egress.private_origin` and the channel plus origin. The pin inventory keeps both senders in the conditional shape and refuses a bare true or false in either. Refs #947
158 lines
6.4 KiB
TypeScript
158 lines
6.4 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;
|
|
/**
|
|
* Stable machine code an API surface may forward as `meta.errorCode`. Set
|
|
* only for a refusal the caller can act on — today the private-origin
|
|
* policy (#947), which the test routes answer with 422 and a reason
|
|
* instead of a bare 500.
|
|
*/
|
|
errorCode?: 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" | "webhook",
|
|
): { hardReject: boolean; reason: string } {
|
|
if (status === undefined) {
|
|
return { hardReject: false, reason: `${service}_network_error` };
|
|
}
|
|
if (service === "webhook") {
|
|
// A user-supplied relay (Gotify / Discord / Slack / Matrix / HA). 410 Gone
|
|
// and 404 Not Found mean the endpoint was deleted — permanent, do not
|
|
// retry. 401/403 mean the shared secret is wrong — also permanent until
|
|
// the user fixes the config, so we hard-reject and surface the reason in
|
|
// the Settings card rather than burning the backoff schedule on a
|
|
// misconfiguration. Everything else (5xx, 429, timeout) is transient.
|
|
if (status === 410) return { hardReject: true, reason: "webhook_410_gone" };
|
|
if (status === 404)
|
|
return { hardReject: true, reason: "webhook_404_not_found" };
|
|
if (status === 401 || status === 403)
|
|
return { hardReject: true, reason: "webhook_auth_rejected" };
|
|
return { hardReject: false, reason: `webhook_${status}` };
|
|
}
|
|
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}` };
|
|
}
|