// Package notify tells the web app that a counter reached its limit. // // One URL template per level (org_url, address_url, domain_url) is filled // with the event and fetched with GET + basic auth, in the background, with // retries. The web app (migadu_web GET /api/limits) decides what happens to // the customer; this package only delivers the event. package notify import ( "context" "errors" "fmt" "io" "net/http" "net/http/httptrace" "net/url" "strconv" "strings" "sync" "sync/atomic" "time" ) // Event kinds: a limit breach, or the early warning before it. const ( KindLimit = "limit" KindWarning = "warning" ) // Config holds the notification settings. type Config struct { OrgURL string // $LEVEL=O AddressURL string // $LEVEL=A DomainURL string // $LEVEL=D // Early-warning URLs, per level. They must not be the breach URLs: the // web app treats every call to those as a breach. WarnOrgURL string WarnAddressURL string WarnDomainURL string User string // basic auth; empty = no Authorization header Password string Timeout time.Duration // per attempt (default 15s) Attempts int // total attempts (default 5) Backoff time.Duration // first retry delay, doubled each retry (default 1s) MaxConcurrent int // notifications in flight (default 8) } // URLFor returns the template for a kind and level, "" when disabled. func (c Config) URLFor(kind, level string) string { warn := kind == KindWarning switch level { case "O": if warn { return c.WarnOrgURL } return c.OrgURL case "A": if warn { return c.WarnAddressURL } return c.AddressURL case "D": if warn { return c.WarnDomainURL } return c.DomainURL } return "" } // URLName returns the config key that serves a kind and level, for log lines. func URLName(kind, level string) string { prefix := "" if kind == KindWarning { prefix = "warn_" } switch level { case "O": return prefix + "org_url" case "A": return prefix + "address_url" case "D": return prefix + "domain_url" } return "" } // Event is one "limit reached" or early-warning occurrence. type Event struct { Kind string // KindLimit (default) or KindWarning TraceID string Route string // in / out, for log correlation Level string // A, D or O ID string // address, domain or organisation id Flow string // i or o Address string // the address that triggered it ($ADDRESS / by=) Count int64 // counter value after the increment ($COUNT) Limit int64 // configured limit ($LIMIT) Percent int64 // warning threshold in percent ($PERCENT) } // KindName returns the event's kind, defaulting to a limit breach. func (e Event) KindName() string { if e.Kind == "" { return KindLimit } return e.Kind } // BuildURL fills the template placeholders with URL-escaped event values. // $PERIOD is always "d" (per day). func BuildURL(tmpl string, ev Event) string { esc := url.QueryEscape return strings.NewReplacer( "$LEVEL", esc(ev.Level), "$LIMIT", strconv.FormatInt(ev.Limit, 10), "$ID", esc(ev.ID), "$FLOW", esc(ev.Flow), "$PERIOD", "d", "$PERCENT", strconv.FormatInt(ev.Percent, 10), "$ADDRESS", esc(ev.Address), "$COUNT", strconv.FormatInt(ev.Count, 10), ).Replace(tmpl) } // Hooks receive the outcome of every dispatched notification (transaction // log, metrics). Nil hooks are ignored. type Hooks struct { Sent func(ev Event, url string, attempt int) Failed func(ev Event, url string, attempts int, err error) } // Notifier sends events in the background. type Notifier struct { cfg Config hooks Hooks client *http.Client sem chan struct{} wg sync.WaitGroup quit chan struct{} mu sync.Mutex closed bool quitOnce sync.Once } // New applies defaults and returns a ready Notifier. func New(cfg Config, hooks Hooks) *Notifier { if cfg.Timeout <= 0 { cfg.Timeout = 15 * time.Second } if cfg.Attempts <= 0 { cfg.Attempts = 5 } if cfg.Backoff <= 0 { cfg.Backoff = time.Second } if cfg.MaxConcurrent <= 0 { cfg.MaxConcurrent = 8 } return &Notifier{ cfg: cfg, hooks: hooks, client: &http.Client{Timeout: cfg.Timeout}, sem: make(chan struct{}, cfg.MaxConcurrent), quit: make(chan struct{}), } } // Notify dispatches ev in the background. It returns false, without doing // anything, when no URL is configured for the level or after Shutdown. func (n *Notifier) Notify(ev Event) bool { tmpl := n.cfg.URLFor(ev.KindName(), ev.Level) if tmpl == "" { return false } n.mu.Lock() if n.closed { n.mu.Unlock() return false } n.wg.Add(1) n.mu.Unlock() u := BuildURL(tmpl, ev) go func() { defer n.wg.Done() n.send(ev, u) }() return true } // send delivers ev, holding a concurrency slot only while a request is in // flight, never during a backoff. func (n *Notifier) send(ev Event, u string) { var lastErr error attempts := 0 for attempts < n.cfg.Attempts { if attempts > 0 && !n.wait(n.cfg.Backoff<<(attempts-1)) { lastErr = fmt.Errorf("shutting down after attempt %d: %w", attempts, lastErr) break } select { case n.sem <- struct{}{}: case <-n.quit: n.failed(ev, u, attempts, errors.New("shutting down before the request was sent")) return } err, retry := n.attempt(u) <-n.sem attempts++ if err == nil { if n.hooks.Sent != nil { n.hooks.Sent(ev, u, attempts) } return } lastErr = err if !retry { break } } n.failed(ev, u, attempts, lastErr) } func (n *Notifier) failed(ev Event, u string, attempts int, err error) { if n.hooks.Failed != nil { n.hooks.Failed(ev, u, attempts, err) } } // wait sleeps for d and reports false when the shutdown deadline passed first. func (n *Notifier) wait(d time.Duration) bool { select { case <-time.After(d): return true case <-n.quit: return false } } // attempt performs one GET. retry is true only when a repeat cannot duplicate // the notification or the web app said it failed: the request never left, or // the answer was 429 or 5xx. A request that was sent but got no answer may // have been acted on, and 4xx (wrong credentials, wrong URL) will not fix // itself. func (n *Notifier) attempt(u string) (err error, retry bool) { req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return err, false } var wrote atomic.Bool req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{ WroteRequest: func(info httptrace.WroteRequestInfo) { if info.Err == nil { wrote.Store(true) } }, })) if n.cfg.User != "" { req.SetBasicAuth(n.cfg.User, n.cfg.Password) } req.Header.Set("User-Agent", "migadu_counters") resp, err := n.client.Do(req) if err != nil { if wrote.Load() { return fmt.Errorf("outcome unknown, request was sent: %w", err), false } return err, true } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return nil, false case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: return fmt.Errorf("unexpected status %d", resp.StatusCode), true default: return fmt.Errorf("unexpected status %d", resp.StatusCode), false } } // Shutdown refuses new events and waits for in-flight notifications and their // pending retries until ctx expires; then remaining retries are abandoned. // Safe to call more than once. func (n *Notifier) Shutdown(ctx context.Context) error { n.mu.Lock() n.closed = true n.mu.Unlock() done := make(chan struct{}) go func() { n.wg.Wait() close(done) }() select { case <-done: return nil case <-ctx.Done(): n.quitOnce.Do(func() { close(n.quit) }) return errors.Join(ctx.Err(), errors.New("notifications still in flight")) } }