notify: a failed Mattermost delivery's error names Mattermost (closes #227)
check / check (push) Canceled after 0s

Mattermost is sent by the Slack sender, which wrapped every HTTP error
status in ErrSlackFailed, so a Mattermost endpoint answering 503 was
logged as "slack notification failed". The sender now takes the error
to wrap: the Slack endpoint passes ErrSlackFailed and the Mattermost
endpoint passes ErrMattermostFailed, which was defined but unused.

A new delivery test sets both endpoints to a stand-in server answering
503 and checks the error logged for each names its own endpoint.

Model: opus-5-5
This commit was merged in pull request #240.
This commit is contained in:
2026-10-02 08:40:19 +02:00
parent ceb24c5004
commit 26c9c74d8e
4 changed files with 82 additions and 7 deletions
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: a Mattermost webhook that answers an HTTP error is logged as
`mattermost notification failed`, not as a Slack failure (closes #227).
- 2026-10-02: durations in the log are written as text such as `2m0s`, not as a - 2026-10-02: durations in the log are written as text such as `2m0s`, not as a
bare count of nanoseconds (closes #228). bare count of nanoseconds (closes #228).
- 2026-10-02: a watched name whose nameservers answer with a CNAME and no - 2026-10-02: a watched name whose nameservers answer with a CNAME and no
+69 -3
View File
@@ -6,9 +6,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -413,7 +415,8 @@ func sendSlackInfo(
svc *notify.Service, target *url.URL, svc *notify.Service, target *url.URL,
) error { ) error {
return svc.SendSlack( return svc.SendSlack(
context.Background(), target, "t", "m", prioInfo, context.Background(), target, notify.ErrSlackFailed,
"t", "m", prioInfo,
) )
} }
@@ -506,6 +509,7 @@ func TestSendSlackPayloadFields(t *testing.T) {
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), context.Background(),
webhookURL, webhookURL,
notify.ErrSlackFailed,
"Alert Title", "Alert Title",
"Alert body text", "Alert body text",
"warning", "warning",
@@ -608,7 +612,8 @@ func TestSendSlackAllColors(t *testing.T) {
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), context.Background(),
webhookURL, "t", "m", tc.priority, webhookURL, notify.ErrSlackFailed,
"t", "m", tc.priority,
) )
if err != nil { if err != nil {
t.Fatalf("SendSlack error: %v", err) t.Fatalf("SendSlack error: %v", err)
@@ -659,7 +664,8 @@ func TestSendSlackNetworkError(t *testing.T) {
) )
err := svc.SendSlack( err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "info", context.Background(), webhookURL, notify.ErrSlackFailed,
"t", "m", "info",
) )
if err == nil { if err == nil {
t.Fatal("expected error for network failure") t.Fatal("expected error for network failure")
@@ -1028,6 +1034,66 @@ func TestSendNotificationMattermostError(t *testing.T) {
) )
} }
// TestSendNotificationErrorNamesEndpoint verifies that, with both
// Slack and Mattermost set, a failed delivery's logged error names
// the endpoint that failed. Both are sent by the Slack sender.
func TestSendNotificationErrorNamesEndpoint(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}),
)
defer srv.Close()
target, _ := url.Parse(srv.URL)
svc, logs := newLoggingService(http.DefaultTransport)
svc.SetSlackWebhookURL(target)
svc.SetMattermostWebhookURL(target)
svc.SetSleepFunc(instantSleep)
svc.SetRetryConfig(notify.RetryConfig{
MaxRetries: 1,
BaseDelay: time.Millisecond,
MaxDelay: time.Millisecond,
})
svc.SendNotification(
context.Background(), "t", "m", prioError,
)
waitForCondition(t, func() bool {
return svc.OutstandingDeliveries() == 0
})
got := map[string]string{}
for line := range strings.Lines(logs.String()) {
var record struct {
Msg string `json:"msg"`
Endpoint string `json:"endpoint"`
Error string `json:"error"`
}
_ = json.Unmarshal([]byte(line), &record)
if record.Msg == "failed to send notification after retries" {
got[record.Endpoint] = record.Error
}
}
want := map[string]string{
"slack": "slack notification failed: status 503",
"mattermost": "mattermost notification failed: status 503",
}
if !maps.Equal(got, want) {
t.Errorf("logged errors = %v, want %v", got, want)
}
}
// ── SlackPayload JSON marshaling ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
func TestSlackPayloadJSON(t *testing.T) { func TestSlackPayloadJSON(t *testing.T) {
+2 -1
View File
@@ -85,10 +85,11 @@ func (svc *Service) SendNtfy(
func (svc *Service) SendSlack( func (svc *Service) SendSlack(
ctx context.Context, ctx context.Context,
webhookURL *url.URL, webhookURL *url.URL,
failed error,
title, message, priority string, title, message, priority string,
) error { ) error {
return svc.sendSlack( return svc.sendSlack(
ctx, webhookURL, title, message, priority, ctx, webhookURL, failed, title, message, priority,
) )
} }
+9 -3
View File
@@ -277,7 +277,8 @@ func (svc *Service) dispatchSlack(
svc.dispatch(ctx, "slack", func(c context.Context) error { svc.dispatch(ctx, "slack", func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.slackWebhookURL, title, message, priority, c, svc.slackWebhookURL, ErrSlackFailed,
title, message, priority,
) )
}) })
} }
@@ -294,7 +295,7 @@ func (svc *Service) dispatchMattermost(
ctx, "mattermost", ctx, "mattermost",
func(c context.Context) error { func(c context.Context) error {
return svc.sendSlack( return svc.sendSlack(
c, svc.mattermostWebhookURL, c, svc.mattermostWebhookURL, ErrMattermostFailed,
title, message, priority, title, message, priority,
) )
}, },
@@ -370,9 +371,14 @@ type SlackAttachment struct {
Text string `json:"text"` Text string `json:"text"`
} }
// sendSlack posts to a Slack or Mattermost incoming webhook, which
// take the same payload. An HTTP error status is returned wrapped in
// failed, ErrSlackFailed or ErrMattermostFailed, 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,
failed error,
title, message, priority string, title, message, priority string,
) error { ) error {
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
@@ -420,7 +426,7 @@ func (svc *Service) sendSlack(
if resp.StatusCode >= httpStatusClientError { if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf( return fmt.Errorf(
"%w: status %d", "%w: status %d",
ErrSlackFailed, resp.StatusCode, failed, resp.StatusCode,
) )
} }