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.
This commit is contained in:
2026-05-23 13:18:04 -04:00
parent ae141c62ba
commit 8e6d97710b
3 changed files with 32 additions and 18 deletions
+17 -6
View File
@@ -2,6 +2,7 @@ package alert
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
@@ -15,7 +16,7 @@ import (
var alertClient = &http.Client{Timeout: 10 * time.Second} var alertClient = &http.Client{Timeout: 10 * time.Second}
type Provider interface { type Provider interface {
Send(title, message string) error Send(ctx context.Context, title, message string) error
} }
type PayloadFunc func(title, message string) ([]byte, error) type PayloadFunc func(title, message string) ([]byte, error)
@@ -25,12 +26,17 @@ type HTTPProvider struct {
Payload PayloadFunc 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) body, err := h.Payload(title, message)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@@ -170,7 +176,12 @@ type EmailProvider struct {
Host, Port, User, Pass, To, From string 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) auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
msg := []byte("To: " + e.To + "\r\n" + msg := []byte("To: " + e.To + "\r\n" +
"Subject: Go-Upkeep: " + title + "\r\n" + "Subject: Go-Upkeep: " + title + "\r\n" +
@@ -187,9 +198,9 @@ type NtfyProvider struct {
Password string 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 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 { if err != nil {
return err return err
} }
+10 -9
View File
@@ -1,6 +1,7 @@
package alert package alert
import ( import (
"context"
"encoding/json" "encoding/json"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"net/http" "net/http"
@@ -17,7 +18,7 @@ func TestHTTPProviderDiscord(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) 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) t.Fatalf("Send: %v", err)
} }
@@ -35,7 +36,7 @@ func TestHTTPProviderSlack(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}}) 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) t.Fatalf("Send: %v", err)
} }
@@ -53,7 +54,7 @@ func TestHTTPProviderWebhook(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}}) 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) t.Fatalf("Send: %v", err)
} }
@@ -69,7 +70,7 @@ func TestHTTPProviderErrorOnHTTP4xx(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) 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") t.Fatal("expected error on 403 response")
} }
} }
@@ -89,7 +90,7 @@ func TestNtfyProvider(t *testing.T) {
"url": srv.URL, "url": srv.URL,
"topic": "test", "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) t.Fatalf("Send: %v", err)
} }
@@ -110,7 +111,7 @@ func TestHTTPProviderTelegram(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")} 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) t.Fatalf("Send: %v", err)
} }
if received["chat_id"] != "12345" { if received["chat_id"] != "12345" {
@@ -133,7 +134,7 @@ func TestHTTPProviderPagerDuty(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")} 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) t.Fatalf("Send: %v", err)
} }
if received["routing_key"] != "test-key" { if received["routing_key"] != "test-key" {
@@ -160,7 +161,7 @@ func TestHTTPProviderPushover(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")} 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) t.Fatalf("Send: %v", err)
} }
if received["token"] != "app-tok" { if received["token"] != "app-tok" {
@@ -183,7 +184,7 @@ func TestHTTPProviderGotify(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")} 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) t.Fatalf("Send: %v", err)
} }
if received["title"] != "Alert" || received["message"] != "Down" { if received["title"] != "Alert" || received["message"] != "Down" {
+5 -3
View File
@@ -25,7 +25,7 @@ type Engine struct {
histMu sync.RWMutex histMu sync.RWMutex
histories map[int]*SiteHistory histories map[int]*SiteHistory
tokenIndex map[string]int tokenIndex map[string]int // protected by mu
probeResultsMu sync.RWMutex probeResultsMu sync.RWMutex
probeResults map[int]map[string]NodeResult 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) { func (e *Engine) triggerAlert(alertID int, title, message string) {
cfg, err := e.db.GetAlert(alertID) cfg, err := e.db.GetAlert(alertID)
if err != nil { if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return return
} }
provider := alert.GetProvider(cfg) provider := alert.GetProvider(cfg)
@@ -440,8 +441,9 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
_ = ctx if err := provider.Send(ctx, title, message); err != nil {
_ = provider.Send(title, message) e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
}
}() }()
} }
} }