notify: a reply other than 2xx is a failed delivery (closes #287)
check / check (push) Waiting to run

A notification endpoint that answered with a redirect was counted as
delivered: the request is not sent through a client, so the redirect
is never followed, and only a status of 400 or more counted as a
failure. Now any status outside 200-299 fails the delivery for ntfy,
Slack and Mattermost, so it is retried and logged like the others.

Model: opus-5-5
This commit is contained in:
2026-10-15 14:02:27 +00:00
parent dee69c4780
commit fafb53da35
5 changed files with 81 additions and 36 deletions
+1 -1
View File
@@ -286,7 +286,7 @@ includes:
Each endpoint is sent each notification on its own, in the background. A Each endpoint is sent each notification on its own, in the background. A
delivery that fails (a network error, no reply within 10 seconds, or an HTTP delivery that fails (a network error, no reply within 10 seconds, or an HTTP
status of 400 or more) is retried up to 5 times: the first retry after about 1 status other than 2xx) is retried up to 5 times: the first retry after about 1
second, each wait after that twice as long up to 60 seconds, every wait varied second, each wait after that twice as long up to 60 seconds, every wait varied
at random by up to 25%. A delivery still failing after that is logged and at random by up to 25%. A delivery still failing after that is logged and
dropped. dropped.
+2
View File
@@ -19,6 +19,8 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Completed Steps # Completed Steps
- 2026-10-15: a notification endpoint that answers with a redirect, or any
status other than 2xx, is a failed delivery, retried and logged (closes #287).
- 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it - 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it
has failed only when none of them gives a usable reply (closes #285). has failed only when none of them gives a usable reply (closes #285).
- 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`, - 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`,
+37 -27
View File
@@ -1185,6 +1185,42 @@ func sendToEachEndpoint(t *testing.T, baseURL, title string) string {
return logs.String() return logs.String()
} }
// assertEachEndpointRetriedThenFailed fails the test unless, for each
// of ntfy, Slack and Mattermost, the lines of logs whose error
// contains reason are the retry line and then the final failure line.
func assertEachEndpointRetriedThenFailed(
t *testing.T, logs, reason string,
) {
t.Helper()
// For each endpoint, the messages of its lines whose error
// contains reason, in the order they were logged.
got := map[string][]string{}
for _, record := range parseLog(t, logs) {
if strings.Contains(record.Error, reason) {
got[record.Endpoint] = append(
got[record.Endpoint], record.Msg,
)
}
}
lines := []string{
"notification delivery failed, retrying",
"failed to send notification after retries",
}
want := map[string][]string{
endpointNtfy: lines,
endpointSlack: lines,
endpointMattermost: lines,
}
if !maps.EqualFunc(got, want, slices.Equal) {
t.Errorf("lines whose error contains %q = %v, want %v",
reason, got, want)
}
}
// TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug // TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug
// logging on, a delivery to each endpoint logs the endpoint's name and // logging on, a delivery to each endpoint logs the endpoint's name and
// the title, and no part of the URL's path or query. // the title, and no part of the URL's path or query.
@@ -1240,33 +1276,7 @@ func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) {
logs := sendToEachEndpoint(t, srv.URL, "t") logs := sendToEachEndpoint(t, srv.URL, "t")
assertNoWebhookSecret(t, logs) assertNoWebhookSecret(t, logs)
assertEachEndpointRetriedThenFailed(t, logs, "connection refused")
// For each endpoint, the messages of its lines whose error names
// the refused connection, in the order they were logged.
got := map[string][]string{}
for _, record := range parseLog(t, logs) {
if strings.Contains(record.Error, "connection refused") {
got[record.Endpoint] = append(
got[record.Endpoint], record.Msg,
)
}
}
lines := []string{
"notification delivery failed, retrying",
"failed to send notification after retries",
}
want := map[string][]string{
endpointNtfy: lines,
endpointSlack: lines,
endpointMattermost: lines,
}
if !maps.EqualFunc(got, want, slices.Equal) {
t.Errorf("lines whose error names the refused connection = %v, "+
"want %v", got, want)
}
} }
// ── SlackPayload JSON marshaling ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
+11 -8
View File
@@ -25,9 +25,6 @@ import (
// HTTP client timeout. // HTTP client timeout.
const httpClientTimeout = 10 * time.Second const httpClientTimeout = 10 * time.Second
// HTTP status code thresholds.
const httpStatusClientError = 400
// Sentinel errors for notification failures. // Sentinel errors for notification failures.
var ( var (
// ErrNtfyFailed indicates the ntfy request failed. // ErrNtfyFailed indicates the ntfy request failed.
@@ -344,7 +341,10 @@ func (svc *Service) sendNtfy(
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= httpStatusClientError { // Only a 2xx reply means ntfy took the notification. A
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
ErrNtfyFailed, resp.StatusCode, ErrNtfyFailed, resp.StatusCode,
@@ -385,9 +385,9 @@ type SlackAttachment struct {
// sendSlack posts to a Slack or Mattermost incoming webhook, which // sendSlack posts to a Slack or Mattermost incoming webhook, which
// take the same payload. endpoint, "slack" or "mattermost", names // take the same payload. endpoint, "slack" or "mattermost", names
// the webhook in the log in place of its URL, which lets anyone who // the webhook in the log in place of its URL, which lets anyone who
// has it post to the channel. An HTTP error status is returned // has it post to the channel. A reply status other than 2xx is
// wrapped in failed, ErrSlackFailed or ErrMattermostFailed, so the // returned wrapped in failed, ErrSlackFailed or ErrMattermostFailed,
// error names the endpoint. // so the error names the endpoint.
func (svc *Service) sendSlack( func (svc *Service) sendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
@@ -437,7 +437,10 @@ func (svc *Service) sendSlack(
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= httpStatusClientError { // Only a 2xx reply means the webhook took the notification. A
// redirect is not followed, so it is a failure too.
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
failed, resp.StatusCode, failed, resp.StatusCode,
+30
View File
@@ -536,3 +536,33 @@ func TestSendNotificationPermanentFailureLogsError(
return attempts >= 3 return attempts >= 3
}) })
} }
// TestSendNotificationRedirectFails checks that a 302 reply is a
// failed delivery at each endpoint: the server is sent the first
// attempt and the retry, and each endpoint logs a retry line and
// then the final failure line, both naming the 302.
func TestSendNotificationRedirectFails(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Redirect(w, r, "/moved", http.StatusFound)
}),
)
defer srv.Close()
logs := sendToEachEndpoint(t, srv.URL, "t")
// ntfy, Slack and Mattermost each send once and retry once.
const wantRequests = 3 * 2
if sent := requests.Load(); sent != wantRequests {
t.Errorf("server got %d requests, want %d", sent, wantRequests)
}
assertEachEndpointRetriedThenFailed(t, logs, "status 302")
}