feat(monitor/backend): events, ratelimit, bus core packages with TDD
This commit is contained in:
parent
73c56333e6
commit
bc9c2d06eb
|
|
@ -15,6 +15,8 @@ require (
|
||||||
github.com/knadh/koanf/v2 v2.1.2
|
github.com/knadh/koanf/v2 v2.1.2
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.12
|
github.com/lestrrat-go/jwx/v3 v3.0.12
|
||||||
github.com/redis/go-redis/v9 v9.7.3
|
github.com/redis/go-redis/v9 v9.7.3
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
go.opentelemetry.io/otel v1.41.0
|
go.opentelemetry.io/otel v1.41.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0
|
go.opentelemetry.io/otel/sdk v1.35.0
|
||||||
|
|
@ -27,6 +29,7 @@ require (
|
||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
|
@ -52,6 +55,7 @@ require (
|
||||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/valyala/fastjson v1.6.4 // indirect
|
github.com/valyala/fastjson v1.6.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
|
@ -66,4 +70,5 @@ require (
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
|
||||||
|
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// bus.go
|
||||||
|
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultBufferSize = 512
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
BufferSize int
|
||||||
|
Persister Persister
|
||||||
|
Broadcaster Broadcaster
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bus struct {
|
||||||
|
ch chan events.Event
|
||||||
|
persister Persister
|
||||||
|
broadcaster Broadcaster
|
||||||
|
logger *slog.Logger
|
||||||
|
dropped atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Bus {
|
||||||
|
if cfg.BufferSize <= 0 {
|
||||||
|
cfg.BufferSize = defaultBufferSize
|
||||||
|
}
|
||||||
|
logger := cfg.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Bus{
|
||||||
|
ch: make(chan events.Event, cfg.BufferSize),
|
||||||
|
persister: cfg.Persister,
|
||||||
|
broadcaster: cfg.Broadcaster,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bus) Emit(ev events.Event) {
|
||||||
|
select {
|
||||||
|
case b.ch <- ev:
|
||||||
|
default:
|
||||||
|
b.dropped.Add(1)
|
||||||
|
b.logger.Warn("event bus full, dropped",
|
||||||
|
"topic", ev.Topic, "source", ev.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bus) DroppedCount() uint64 {
|
||||||
|
return b.dropped.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bus) Run(ctx context.Context) error {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case ev := <-b.ch:
|
||||||
|
if b.persister != nil {
|
||||||
|
if err := b.persister.Save(ctx, ev); err != nil {
|
||||||
|
b.logger.Error("persist event failed",
|
||||||
|
"err", err, "topic", ev.Topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.broadcaster != nil {
|
||||||
|
payload, err := json.Marshal(ev.Payload)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("marshal payload failed",
|
||||||
|
"err", err, "topic", ev.Topic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.broadcaster.Broadcast(string(ev.Topic), payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// bus_test.go
|
||||||
|
|
||||||
|
package bus_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/bus"
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
type capturePersister struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
saved []events.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capturePersister) Save(_ context.Context, ev events.Event) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.saved = append(c.saved, ev)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capturePersister) Count() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return len(c.saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureBroadcaster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sent []bus.Broadcast
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *captureBroadcaster) Broadcast(topic string, payload []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.sent = append(b.sent, bus.Broadcast{Topic: topic, Payload: payload})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *captureBroadcaster) Count() int {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_EmitFlowsThroughDispatcher(t *testing.T) {
|
||||||
|
persist := &capturePersister{}
|
||||||
|
cast := &captureBroadcaster{}
|
||||||
|
|
||||||
|
b := bus.New(bus.Config{
|
||||||
|
BufferSize: 16,
|
||||||
|
Persister: persist,
|
||||||
|
Broadcaster: cast,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- b.Run(ctx) }()
|
||||||
|
|
||||||
|
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
b.Emit(events.Event{
|
||||||
|
Topic: events.TopicHeartbeat,
|
||||||
|
Timestamp: now,
|
||||||
|
Source: "heartbeat",
|
||||||
|
Payload: map[string]string{"ts": now.Format(time.RFC3339Nano)},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return persist.Count() == 1 && cast.Count() == 1
|
||||||
|
}, 2*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
require.ErrorIs(t, <-done, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_DropOnFullBuffer(t *testing.T) {
|
||||||
|
persist := &capturePersister{}
|
||||||
|
cast := &captureBroadcaster{}
|
||||||
|
|
||||||
|
b := bus.New(bus.Config{
|
||||||
|
BufferSize: 1,
|
||||||
|
Persister: persist,
|
||||||
|
Broadcaster: cast,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"})
|
||||||
|
}
|
||||||
|
require.Greater(t, b.DroppedCount(), uint64(0))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// persister.go
|
||||||
|
|
||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Persister interface {
|
||||||
|
Save(ctx context.Context, ev events.Event) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Broadcaster interface {
|
||||||
|
Broadcast(topic string, payload []byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Broadcast struct {
|
||||||
|
Topic string
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// event.go
|
||||||
|
|
||||||
|
package events
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Topic Topic `json:"topic"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Payload any `json:"payload"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// event_test.go
|
||||||
|
|
||||||
|
package events_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvent_JSONRoundTrip(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
original := events.Event{
|
||||||
|
Topic: events.TopicHeartbeat,
|
||||||
|
Timestamp: now,
|
||||||
|
Source: "heartbeat",
|
||||||
|
Payload: map[string]any{"ts": now.Format(time.RFC3339Nano)},
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var decoded events.Event
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||||
|
|
||||||
|
require.Equal(t, original.Topic, decoded.Topic)
|
||||||
|
require.Equal(t, original.Source, decoded.Source)
|
||||||
|
require.True(t, original.Timestamp.Equal(decoded.Timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopic_IsValid(t *testing.T) {
|
||||||
|
require.True(t, events.TopicHeartbeat.IsValid())
|
||||||
|
require.True(t, events.TopicCVENew.IsValid())
|
||||||
|
require.False(t, events.Topic("bogus").IsValid())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllTopics_AllValid(t *testing.T) {
|
||||||
|
all := events.AllTopics()
|
||||||
|
require.NotEmpty(t, all)
|
||||||
|
for _, top := range all {
|
||||||
|
require.True(t, top.IsValid(), "topic %q should be valid", top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// topic.go
|
||||||
|
|
||||||
|
package events
|
||||||
|
|
||||||
|
type Topic string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TopicHeartbeat Topic = "heartbeat"
|
||||||
|
TopicScanFirehose Topic = "scan_firehose"
|
||||||
|
TopicInternetOutage Topic = "internet_outage"
|
||||||
|
TopicBGPHijack Topic = "bgp_hijack"
|
||||||
|
TopicCVENew Topic = "cve_new"
|
||||||
|
TopicCVEUpdated Topic = "cve_updated"
|
||||||
|
TopicEPSS Topic = "epss"
|
||||||
|
TopicKEVAdded Topic = "kev_added"
|
||||||
|
TopicRansomwareVictim Topic = "ransomware_victim"
|
||||||
|
TopicCoinbasePrice Topic = "coinbase_price"
|
||||||
|
TopicEarthquake Topic = "earthquake"
|
||||||
|
TopicSpaceWeather Topic = "space_weather"
|
||||||
|
TopicWikipediaITN Topic = "wiki_itn"
|
||||||
|
TopicGDELTSpike Topic = "gdelt_spike"
|
||||||
|
TopicISSPosition Topic = "iss_position"
|
||||||
|
TopicCollectorState Topic = "collector_state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t Topic) String() string { return string(t) }
|
||||||
|
|
||||||
|
func (t Topic) IsValid() bool {
|
||||||
|
switch t {
|
||||||
|
case TopicHeartbeat, TopicScanFirehose, TopicInternetOutage, TopicBGPHijack,
|
||||||
|
TopicCVENew, TopicCVEUpdated, TopicEPSS, TopicKEVAdded,
|
||||||
|
TopicRansomwareVictim, TopicCoinbasePrice, TopicEarthquake,
|
||||||
|
TopicSpaceWeather, TopicWikipediaITN, TopicGDELTSpike,
|
||||||
|
TopicISSPosition, TopicCollectorState:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllTopics() []Topic {
|
||||||
|
return []Topic{
|
||||||
|
TopicHeartbeat,
|
||||||
|
TopicScanFirehose,
|
||||||
|
TopicInternetOutage,
|
||||||
|
TopicBGPHijack,
|
||||||
|
TopicCVENew,
|
||||||
|
TopicCVEUpdated,
|
||||||
|
TopicEPSS,
|
||||||
|
TopicKEVAdded,
|
||||||
|
TopicRansomwareVictim,
|
||||||
|
TopicCoinbasePrice,
|
||||||
|
TopicEarthquake,
|
||||||
|
TopicSpaceWeather,
|
||||||
|
TopicWikipediaITN,
|
||||||
|
TopicGDELTSpike,
|
||||||
|
TopicISSPosition,
|
||||||
|
TopicCollectorState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// limiter.go
|
||||||
|
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sony/gobreaker/v2"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Name string
|
||||||
|
Rate rate.Limit
|
||||||
|
Burst int
|
||||||
|
ConsecutiveFailureBudget uint32
|
||||||
|
BreakerTimeout time.Duration
|
||||||
|
OnStateChange func(name string, from, to gobreaker.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Limiter[T any] struct {
|
||||||
|
name string
|
||||||
|
rl *rate.Limiter
|
||||||
|
cb *gobreaker.CircuitBreaker[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
func New[T any](cfg Config) *Limiter[T] {
|
||||||
|
if cfg.ConsecutiveFailureBudget == 0 {
|
||||||
|
cfg.ConsecutiveFailureBudget = 5
|
||||||
|
}
|
||||||
|
if cfg.BreakerTimeout == 0 {
|
||||||
|
cfg.BreakerTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
settings := gobreaker.Settings{
|
||||||
|
Name: cfg.Name,
|
||||||
|
Timeout: cfg.BreakerTimeout,
|
||||||
|
ReadyToTrip: func(c gobreaker.Counts) bool {
|
||||||
|
return c.ConsecutiveFailures >= cfg.ConsecutiveFailureBudget
|
||||||
|
},
|
||||||
|
OnStateChange: func(name string, from, to gobreaker.State) {
|
||||||
|
slog.Warn("circuit breaker state change",
|
||||||
|
"name", name, "from", from.String(), "to", to.String())
|
||||||
|
if cfg.OnStateChange != nil {
|
||||||
|
cfg.OnStateChange(name, from, to)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &Limiter[T]{
|
||||||
|
name: cfg.Name,
|
||||||
|
rl: rate.NewLimiter(cfg.Rate, cfg.Burst),
|
||||||
|
cb: gobreaker.NewCircuitBreaker[T](settings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter[T]) Do(ctx context.Context, fn func(context.Context) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if err := l.rl.Wait(ctx); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return l.cb.Execute(func() (T, error) { return fn(ctx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter[T]) AdjustRate(newRate rate.Limit) {
|
||||||
|
l.rl.SetLimit(newRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter[T]) Name() string { return l.name }
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// limiter_test.go
|
||||||
|
|
||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sony/gobreaker/v2"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLimiter_AllowsAtNominalRate(t *testing.T) {
|
||||||
|
lim := ratelimit.New[string](ratelimit.Config{
|
||||||
|
Name: "test-allow",
|
||||||
|
Rate: rate.Every(20 * time.Millisecond),
|
||||||
|
Burst: 4,
|
||||||
|
ConsecutiveFailureBudget: 5,
|
||||||
|
BreakerTimeout: 100 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_, err := lim.Do(ctx, func(_ context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
return "ok", nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.Equal(t, 4, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiter_OpensBreakerOnRepeatedFailures(t *testing.T) {
|
||||||
|
lim := ratelimit.New[string](ratelimit.Config{
|
||||||
|
Name: "test-fail",
|
||||||
|
Rate: rate.Every(2 * time.Millisecond),
|
||||||
|
Burst: 10,
|
||||||
|
ConsecutiveFailureBudget: 3,
|
||||||
|
BreakerTimeout: 50 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
failure := errors.New("upstream broken")
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = lim.Do(ctx, func(_ context.Context) (string, error) { return "", failure })
|
||||||
|
}
|
||||||
|
|
||||||
|
called := false
|
||||||
|
_, err := lim.Do(ctx, func(_ context.Context) (string, error) {
|
||||||
|
called = true
|
||||||
|
return "", nil
|
||||||
|
})
|
||||||
|
require.False(t, called, "should not have called function — breaker should be open")
|
||||||
|
require.ErrorIs(t, err, gobreaker.ErrOpenState)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue