First commit Go counter service
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// 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/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the notification settings.
|
||||
type Config struct {
|
||||
OrgURL string // $LEVEL=O
|
||||
AddressURL string // $LEVEL=A
|
||||
DomainURL string // $LEVEL=D
|
||||
User string // basic auth; empty = no Authorization header
|
||||
Password string
|
||||
|
||||
Timeout time.Duration // per attempt (default 5s)
|
||||
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 level, "" when that level is disabled.
|
||||
func (c Config) URLFor(level string) string {
|
||||
switch level {
|
||||
case "O":
|
||||
return c.OrgURL
|
||||
case "A":
|
||||
return c.AddressURL
|
||||
case "D":
|
||||
return c.DomainURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// URLName returns the config key that serves a level, for log lines.
|
||||
func URLName(level string) string {
|
||||
switch level {
|
||||
case "O":
|
||||
return "org_url"
|
||||
case "A":
|
||||
return "address_url"
|
||||
case "D":
|
||||
return "domain_url"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Event is one "limit reached" occurrence.
|
||||
type Event struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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",
|
||||
"$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
|
||||
}
|
||||
|
||||
// New applies defaults and returns a ready Notifier.
|
||||
func New(cfg Config, hooks Hooks) *Notifier {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * 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.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.sem <- struct{}{}
|
||||
defer func() { <-n.sem }()
|
||||
n.send(ev, u)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *Notifier) send(ev Event, u string) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= n.cfg.Attempts; attempt++ {
|
||||
err, retry := n.attempt(u)
|
||||
if err == nil {
|
||||
if n.hooks.Sent != nil {
|
||||
n.hooks.Sent(ev, u, attempt)
|
||||
}
|
||||
return
|
||||
}
|
||||
lastErr = err
|
||||
if !retry {
|
||||
if n.hooks.Failed != nil {
|
||||
n.hooks.Failed(ev, u, attempt, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if attempt < n.cfg.Attempts {
|
||||
select {
|
||||
case <-time.After(n.cfg.Backoff << (attempt - 1)):
|
||||
case <-n.quit:
|
||||
lastErr = fmt.Errorf("shutting down after attempt %d: %w", attempt, err)
|
||||
attempt = n.cfg.Attempts // stop retrying
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.hooks.Failed != nil {
|
||||
n.hooks.Failed(ev, u, n.cfg.Attempts, lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
// attempt performs one GET. retry is false for 4xx: wrong credentials or a
|
||||
// wrong URL will not fix themselves.
|
||||
func (n *Notifier) attempt(u string) (err error, retry bool) {
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
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 {
|
||||
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, stops pending retries and waits for in-flight
|
||||
// requests until ctx expires. Safe to call more than once.
|
||||
func (n *Notifier) Shutdown(ctx context.Context) error {
|
||||
n.mu.Lock()
|
||||
if !n.closed {
|
||||
n.closed = true
|
||||
close(n.quit)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
n.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return errors.Join(ctx.Err(), errors.New("notifications still in flight"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tmpl = "https://admin.example/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT&limit=$LIMIT"
|
||||
|
||||
func TestBuildURL(t *testing.T) {
|
||||
ev := Event{Level: "A", ID: "me@example.com", Flow: "o", Address: "me@example.com", Count: 100, Limit: 100}
|
||||
got := BuildURL(tmpl, ev)
|
||||
want := "https://admin.example/api/limits?level=A&id=me%40example.com&flow=o&period=d&by=me%40example.com&count=100&limit=100"
|
||||
if got != want {
|
||||
t.Errorf("BuildURL =\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
// Dangerous characters in values are escaped, templates without placeholders pass through.
|
||||
ev.ID = "a b&c=d@x.y"
|
||||
u, err := url.Parse(BuildURL(tmpl, ev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Query().Get("id") != "a b&c=d@x.y" {
|
||||
t.Errorf("id round-trip = %q", u.Query().Get("id"))
|
||||
}
|
||||
if got := BuildURL("https://x/static", ev); got != "https://x/static" {
|
||||
t.Errorf("static template changed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLFor(t *testing.T) {
|
||||
cfg := Config{OrgURL: "o", AddressURL: "a", DomainURL: "d"}
|
||||
for level, want := range map[string]string{"O": "o", "A": "a", "D": "d", "X": ""} {
|
||||
if got := cfg.URLFor(level); got != want {
|
||||
t.Errorf("URLFor(%s) = %q, want %q", level, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// capture is an httptest handler recording requests.
|
||||
type capture struct {
|
||||
mu sync.Mutex
|
||||
requests []*http.Request
|
||||
status []int // status to answer per call; last entry repeats
|
||||
calls atomic.Int32
|
||||
delay time.Duration
|
||||
inflight atomic.Int32
|
||||
maxSeen atomic.Int32
|
||||
}
|
||||
|
||||
func (c *capture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
n := c.inflight.Add(1)
|
||||
for {
|
||||
m := c.maxSeen.Load()
|
||||
if n <= m || c.maxSeen.CompareAndSwap(m, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
defer c.inflight.Add(-1)
|
||||
if c.delay > 0 {
|
||||
time.Sleep(c.delay)
|
||||
}
|
||||
i := int(c.calls.Add(1)) - 1
|
||||
c.mu.Lock()
|
||||
c.requests = append(c.requests, r.Clone(context.Background()))
|
||||
c.mu.Unlock()
|
||||
status := http.StatusOK
|
||||
if len(c.status) > 0 {
|
||||
if i < len(c.status) {
|
||||
status = c.status[i]
|
||||
} else {
|
||||
status = c.status[len(c.status)-1]
|
||||
}
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
|
||||
type hooks struct {
|
||||
mu sync.Mutex
|
||||
sent []sentCall
|
||||
failed []failedCall
|
||||
done chan struct{}
|
||||
}
|
||||
type sentCall struct {
|
||||
ev Event
|
||||
url string
|
||||
attempt int
|
||||
}
|
||||
type failedCall struct {
|
||||
ev Event
|
||||
url string
|
||||
attempts int
|
||||
err error
|
||||
}
|
||||
|
||||
func newHooks() *hooks { return &hooks{done: make(chan struct{}, 64)} }
|
||||
|
||||
func (h *hooks) Hooks() Hooks {
|
||||
return Hooks{
|
||||
Sent: func(ev Event, url string, attempt int) {
|
||||
h.mu.Lock()
|
||||
h.sent = append(h.sent, sentCall{ev, url, attempt})
|
||||
h.mu.Unlock()
|
||||
h.done <- struct{}{}
|
||||
},
|
||||
Failed: func(ev Event, url string, attempts int, err error) {
|
||||
h.mu.Lock()
|
||||
h.failed = append(h.failed, failedCall{ev, url, attempts, err})
|
||||
h.mu.Unlock()
|
||||
h.done <- struct{}{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *hooks) wait(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timeout waiting for notification outcome %d/%d", i+1, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fastConfig(orgURL, addrURL, domURL string) Config {
|
||||
return Config{
|
||||
OrgURL: orgURL, AddressURL: addrURL, DomainURL: domURL,
|
||||
User: "api", Password: "secret",
|
||||
Timeout: 2 * time.Second, Attempts: 5, Backoff: time.Millisecond, MaxConcurrent: 8,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_SendsToLevelURLWithBasicAuth(t *testing.T) {
|
||||
org, addr, dom := &capture{}, &capture{}, &capture{}
|
||||
so, sa, sd := httptest.NewServer(org), httptest.NewServer(addr), httptest.NewServer(dom)
|
||||
defer so.Close()
|
||||
defer sa.Close()
|
||||
defer sd.Close()
|
||||
q := "/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
|
||||
h := newHooks()
|
||||
n := New(fastConfig(so.URL+q, sa.URL+q, sd.URL+q), h.Hooks())
|
||||
defer n.Shutdown(context.Background())
|
||||
|
||||
ev := Event{TraceID: "tr", Level: "A", ID: "me@example.com", Flow: "o", Address: "me@example.com", Count: 100, Limit: 100}
|
||||
if !n.Notify(ev) {
|
||||
t.Fatal("Notify returned false for a configured level")
|
||||
}
|
||||
h.wait(t, 1)
|
||||
|
||||
if org.calls.Load() != 0 || dom.calls.Load() != 0 || addr.calls.Load() != 1 {
|
||||
t.Fatalf("calls org=%d addr=%d dom=%d, want only addr", org.calls.Load(), addr.calls.Load(), dom.calls.Load())
|
||||
}
|
||||
r := addr.requests[0]
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
if u, p, ok := r.BasicAuth(); !ok || u != "api" || p != "secret" {
|
||||
t.Errorf("basic auth = %q %q %v", u, p, ok)
|
||||
}
|
||||
qv := r.URL.Query()
|
||||
for k, want := range map[string]string{"level": "A", "id": "me@example.com", "flow": "o", "period": "d", "by": "me@example.com", "count": "100"} {
|
||||
if qv.Get(k) != want {
|
||||
t.Errorf("query %s = %q, want %q", k, qv.Get(k), want)
|
||||
}
|
||||
}
|
||||
if len(h.sent) != 1 || h.sent[0].attempt != 1 || h.sent[0].ev != ev || !strings.HasPrefix(h.sent[0].url, sa.URL) {
|
||||
t.Errorf("sent hook = %+v", h.sent)
|
||||
}
|
||||
|
||||
// Org and domain levels route to their own URLs.
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
n.Notify(Event{Level: "D", ID: "example.com"})
|
||||
h.wait(t, 2)
|
||||
if org.calls.Load() != 1 || dom.calls.Load() != 1 {
|
||||
t.Errorf("calls org=%d dom=%d, want 1 each", org.calls.Load(), dom.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_EmptyURLSkips(t *testing.T) {
|
||||
h := newHooks()
|
||||
n := New(fastConfig("", "", ""), h.Hooks())
|
||||
if n.Notify(Event{Level: "A"}) || n.Notify(Event{Level: "Z"}) {
|
||||
t.Error("Notify should return false when no URL is configured for the level")
|
||||
}
|
||||
if err := n.Shutdown(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(h.sent)+len(h.failed) != 0 {
|
||||
t.Errorf("no hooks expected, got sent=%v failed=%v", h.sent, h.failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_NoBasicAuthWithoutUser(t *testing.T) {
|
||||
c := &capture{}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
|
||||
cfg.User, cfg.Password = "", ""
|
||||
n := New(cfg, h.Hooks())
|
||||
n.Notify(Event{Level: "O"})
|
||||
h.wait(t, 1)
|
||||
n.Shutdown(context.Background())
|
||||
if _, _, ok := c.requests[0].BasicAuth(); ok {
|
||||
t.Error("Authorization header sent although no user is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_RetriesThenSucceeds(t *testing.T) {
|
||||
c := &capture{status: []int{500, 503, 200}}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
n := New(fastConfig(s.URL+"/l?level=$LEVEL", "", ""), h.Hooks())
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
h.wait(t, 1)
|
||||
n.Shutdown(context.Background())
|
||||
if c.calls.Load() != 3 || len(h.sent) != 1 || h.sent[0].attempt != 3 || len(h.failed) != 0 {
|
||||
t.Errorf("calls=%d sent=%+v failed=%+v; want success on attempt 3", c.calls.Load(), h.sent, h.failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_GivesUpAfterAttempts(t *testing.T) {
|
||||
c := &capture{status: []int{500}}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
n := New(fastConfig(s.URL+"/l?level=$LEVEL", "", ""), h.Hooks())
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
h.wait(t, 1)
|
||||
n.Shutdown(context.Background())
|
||||
if c.calls.Load() != 5 || len(h.failed) != 1 || h.failed[0].attempts != 5 || len(h.sent) != 0 {
|
||||
t.Errorf("calls=%d failed=%+v sent=%+v; want 5 attempts then failure", c.calls.Load(), h.failed, h.sent)
|
||||
}
|
||||
if !strings.Contains(h.failed[0].err.Error(), "500") {
|
||||
t.Errorf("failure error should carry the status: %v", h.failed[0].err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4xx means a configuration problem (bad credentials, wrong URL): retrying is pointless.
|
||||
func TestNotify_NoRetryOn4xx(t *testing.T) {
|
||||
c := &capture{status: []int{401}}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
n := New(fastConfig(s.URL+"/l?level=$LEVEL", "", ""), h.Hooks())
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
h.wait(t, 1)
|
||||
n.Shutdown(context.Background())
|
||||
if c.calls.Load() != 1 || len(h.failed) != 1 || h.failed[0].attempts != 1 {
|
||||
t.Errorf("calls=%d failed=%+v; want a single attempt", c.calls.Load(), h.failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_TimeoutIsRetried(t *testing.T) {
|
||||
c := &capture{delay: 300 * time.Millisecond}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
|
||||
cfg.Timeout = 50 * time.Millisecond
|
||||
cfg.Attempts = 2
|
||||
n := New(cfg, h.Hooks())
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
h.wait(t, 1)
|
||||
n.Shutdown(context.Background())
|
||||
if len(h.failed) != 1 || h.failed[0].attempts != 2 {
|
||||
t.Errorf("failed=%+v; want failure after 2 timed-out attempts", h.failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_BoundedConcurrency(t *testing.T) {
|
||||
c := &capture{delay: 50 * time.Millisecond}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
cfg := fastConfig(s.URL+"/l?level=$LEVEL", "", "")
|
||||
cfg.MaxConcurrent = 2
|
||||
n := New(cfg, h.Hooks())
|
||||
for i := 0; i < 10; i++ {
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
}
|
||||
h.wait(t, 10)
|
||||
n.Shutdown(context.Background())
|
||||
if c.maxSeen.Load() > 2 {
|
||||
t.Errorf("max concurrent requests = %d, want <= 2", c.maxSeen.Load())
|
||||
}
|
||||
if c.calls.Load() != 10 {
|
||||
t.Errorf("calls = %d, want 10", c.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown waits for in-flight notifications, bounded by the context.
|
||||
func TestNotify_ShutdownDrainsAndRespectsContext(t *testing.T) {
|
||||
c := &capture{delay: 200 * time.Millisecond}
|
||||
s := httptest.NewServer(c)
|
||||
defer s.Close()
|
||||
h := newHooks()
|
||||
n := New(fastConfig(s.URL+"/l?level=$LEVEL", "", ""), h.Hooks())
|
||||
n.Notify(Event{Level: "O", ID: "7"})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := n.Shutdown(ctx); err == nil {
|
||||
t.Error("Shutdown with an expired context should return its error")
|
||||
}
|
||||
if err := n.Shutdown(context.Background()); err != nil {
|
||||
t.Errorf("second Shutdown: %v", err)
|
||||
}
|
||||
if c.calls.Load() != 1 || len(h.sent) != 1 {
|
||||
t.Errorf("in-flight notification was not completed: calls=%d sent=%d", c.calls.Load(), len(h.sent))
|
||||
}
|
||||
if n.Notify(Event{Level: "O"}) {
|
||||
t.Error("Notify after Shutdown should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Defaults(t *testing.T) {
|
||||
n := New(Config{OrgURL: "https://x/?level=$LEVEL"}, Hooks{})
|
||||
defer n.Shutdown(context.Background())
|
||||
if n.cfg.Attempts != 5 || n.cfg.Backoff != time.Second || n.cfg.MaxConcurrent != 8 || n.cfg.Timeout != 5*time.Second {
|
||||
t.Errorf("defaults not applied: %+v", n.cfg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user