Files
HealthLog/src/lib/notifications/test-delivery-failure.ts
T
Marc-André Bombeck f6a890df8a fix(notifications): answer a failed channel test with its cause
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.

The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.

The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.

Refs #947
2026-09-13 14:10:35 +02:00

110 lines
3.7 KiB
TypeScript

import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
/**
* The code for an HTTP status a relay answered, in the test card's
* vocabulary (`settings.testConnection.errors`). The same split the
* Nightscout and wearable test routes use, plus two the relays need: a 3xx
* (safeFetch never follows a redirect) and a 4xx that is not about the
* credentials or the path, where the relay accepted the connection and
* refused the payload.
*/
export function codeForUpstreamStatus(status: number): string {
if (status === 401 || status === 403) return "credentials_rejected";
if (status === 404 || status === 410) return "endpoint_not_found";
if (status === 429) return "rate_limited";
if (status >= 500) return "upstream_error";
if (status >= 300 && status < 400) return "redirected";
return "upstream_rejected";
}
export interface TestFailureDetail {
/** Absent only for a failure the sender could not name: an internal fault. */
errorCode?: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
}
/** What a failed send outcome says, without the private-origin refusal. */
export function testFailureDetail(result: SendOutcome): TestFailureDetail {
if (result.statusCode !== undefined) {
return {
errorCode: codeForUpstreamStatus(result.statusCode),
upstreamStatus: result.statusCode,
...(result.upstreamBody ? { upstreamBody: result.upstreamBody } : {}),
};
}
if (result.failureCode) {
return {
errorCode: result.failureCode,
...(result.smtpCode !== undefined ? { smtpCode: result.smtpCode } : {}),
};
}
return {};
}
/** One English sentence for the envelope's `error`, the card translates the code. */
export function testFailureSentence(
label: string,
detail: TestFailureDetail,
): string {
if (detail.upstreamStatus !== undefined) {
return `${label} answered HTTP ${detail.upstreamStatus}.`;
}
const smtp =
detail.smtpCode !== undefined ? ` (SMTP ${detail.smtpCode})` : "";
switch (detail.errorCode) {
case "timeout":
return `${label} did not answer in time.`;
case "connection_failed":
return `Could not connect to ${label}.`;
case "credentials_rejected":
return `${label} rejected the credentials${smtp}.`;
case "upstream_rejected":
return `${label} refused the message${smtp}.`;
case "upstream_error":
return `${label} reported a temporary error${smtp}.`;
default:
return `${label} test failed.`;
}
}
/**
* The test routes' answer to a failed send that was not a private-origin
* refusal (those keep their own 422 arm).
*
* The other end, or the way to it, failed: 502 with `meta.errorCode`, the
* upstream status or SMTP code, and what the relay said when it is short
* and holds no secret. A failure the sender could not name is an internal
* fault and stays a 500 with `fallbackMessage`.
*/
export function answerTestDeliveryFailure(
result: SendOutcome,
opts: {
channel: "webhook" | "ntfy" | "email";
label: string;
fallbackMessage: string;
},
): Response {
const detail = testFailureDetail(result);
annotate({
action: { name: `settings.${opts.channel}.test` },
meta: {
success: false,
[`${opts.channel}_test_code`]: detail.errorCode ?? "internal",
...(detail.upstreamStatus !== undefined
? { [`${opts.channel}_test_status`]: detail.upstreamStatus }
: {}),
...(detail.smtpCode !== undefined
? { [`${opts.channel}_test_smtp_code`]: detail.smtpCode }
: {}),
},
});
if (!detail.errorCode) {
return apiError(opts.fallbackMessage, 500);
}
return apiError(testFailureSentence(opts.label, detail), 502, { ...detail });
}