From 8e6d97710beb4af510920ba7a7bfbfc514dd981c Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 23 May 2026 13:18:04 -0400 Subject: [PATCH] fix(alert): add context to Provider.Send, log alert failures Provider.Send now accepts context.Context for timeout/cancellation. HTTPProvider and NtfyProvider use NewRequestWithContext so HTTP alerts respect the 30s deadline. triggerAlert logs send failures and config load errors instead of silently swallowing them. --- internal/alert/alert.go | 23 +++++++++++++++++------ internal/alert/alert_test.go | 19 ++++++++++--------- internal/monitor/monitor.go | 8 +++++--- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index c60013f..12a2f41 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -2,6 +2,7 @@ package alert import ( "bytes" + "context" "encoding/json" "fmt" "go-upkeep/internal/models" @@ -15,7 +16,7 @@ import ( var alertClient = &http.Client{Timeout: 10 * time.Second} type Provider interface { - Send(title, message string) error + Send(ctx context.Context, title, message string) error } type PayloadFunc func(title, message string) ([]byte, error) @@ -25,12 +26,17 @@ type HTTPProvider struct { Payload PayloadFunc } -func (h *HTTPProvider) Send(title, message string) error { +func (h *HTTPProvider) Send(ctx context.Context, title, message string) error { body, err := h.Payload(title, message) if err != nil { return err } - resp, err := alertClient.Post(h.URL, "application/json", bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(ctx, "POST", h.URL, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := alertClient.Do(req) if err != nil { return err } @@ -170,7 +176,12 @@ type EmailProvider struct { Host, Port, User, Pass, To, From string } -func (e *EmailProvider) Send(title, message string) error { +func (e *EmailProvider) Send(ctx context.Context, title, message string) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) msg := []byte("To: " + e.To + "\r\n" + "Subject: Go-Upkeep: " + title + "\r\n" + @@ -187,9 +198,9 @@ type NtfyProvider struct { Password string } -func (n *NtfyProvider) Send(title, message string) error { +func (n *NtfyProvider) Send(ctx context.Context, title, message string) error { url := strings.TrimRight(n.ServerURL, "/") + "/" + n.Topic - req, err := http.NewRequest("POST", url, strings.NewReader(message)) + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(message)) if err != nil { return err } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index 35e1c8d..3314d17 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -1,6 +1,7 @@ package alert import ( + "context" "encoding/json" "go-upkeep/internal/models" "net/http" @@ -17,7 +18,7 @@ func TestHTTPProviderDiscord(t *testing.T) { defer srv.Close() p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) - if err := p.Send("Test Title", "Test Body"); err != nil { + if err := p.Send(context.Background(), "Test Title", "Test Body"); err != nil { t.Fatalf("Send: %v", err) } @@ -35,7 +36,7 @@ func TestHTTPProviderSlack(t *testing.T) { defer srv.Close() p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}}) - if err := p.Send("Alert", "Message"); err != nil { + if err := p.Send(context.Background(), "Alert", "Message"); err != nil { t.Fatalf("Send: %v", err) } @@ -53,7 +54,7 @@ func TestHTTPProviderWebhook(t *testing.T) { defer srv.Close() p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}}) - if err := p.Send("Title", "Body"); err != nil { + if err := p.Send(context.Background(), "Title", "Body"); err != nil { t.Fatalf("Send: %v", err) } @@ -69,7 +70,7 @@ func TestHTTPProviderErrorOnHTTP4xx(t *testing.T) { defer srv.Close() p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) - if err := p.Send("Test", "Test"); err == nil { + if err := p.Send(context.Background(), "Test", "Test"); err == nil { t.Fatal("expected error on 403 response") } } @@ -89,7 +90,7 @@ func TestNtfyProvider(t *testing.T) { "url": srv.URL, "topic": "test", }}) - if err := p.Send("Alert Title", "Alert Body"); err != nil { + if err := p.Send(context.Background(), "Alert Title", "Alert Body"); err != nil { t.Fatalf("Send: %v", err) } @@ -110,7 +111,7 @@ func TestHTTPProviderTelegram(t *testing.T) { defer srv.Close() p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")} - if err := p.Send("Alert", "Down"); err != nil { + if err := p.Send(context.Background(), "Alert", "Down"); err != nil { t.Fatalf("Send: %v", err) } if received["chat_id"] != "12345" { @@ -133,7 +134,7 @@ func TestHTTPProviderPagerDuty(t *testing.T) { defer srv.Close() p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")} - if err := p.Send("Alert", "Down"); err != nil { + if err := p.Send(context.Background(), "Alert", "Down"); err != nil { t.Fatalf("Send: %v", err) } if received["routing_key"] != "test-key" { @@ -160,7 +161,7 @@ func TestHTTPProviderPushover(t *testing.T) { defer srv.Close() p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")} - if err := p.Send("Alert", "Down"); err != nil { + if err := p.Send(context.Background(), "Alert", "Down"); err != nil { t.Fatalf("Send: %v", err) } if received["token"] != "app-tok" { @@ -183,7 +184,7 @@ func TestHTTPProviderGotify(t *testing.T) { defer srv.Close() p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")} - if err := p.Send("Alert", "Down"); err != nil { + if err := p.Send(context.Background(), "Alert", "Down"); err != nil { t.Fatalf("Send: %v", err) } if received["title"] != "Alert" || received["message"] != "Down" { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 284497c..1029d1c 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -25,7 +25,7 @@ type Engine struct { histMu sync.RWMutex histories map[int]*SiteHistory - tokenIndex map[string]int + tokenIndex map[string]int // protected by mu probeResultsMu sync.RWMutex probeResults map[int]map[string]NodeResult @@ -433,6 +433,7 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int func (e *Engine) triggerAlert(alertID int, title, message string) { cfg, err := e.db.GetAlert(alertID) if err != nil { + e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err)) return } provider := alert.GetProvider(cfg) @@ -440,8 +441,9 @@ func (e *Engine) triggerAlert(alertID int, title, message string) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - _ = ctx - _ = provider.Send(title, message) + if err := provider.Send(ctx, title, message); err != nil { + e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err)) + } }() } }