69 lines
1.3 KiB
Go
69 lines
1.3 KiB
Go
package email
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// slidingLimiter is an in-process email send budget (per key).
|
||
|
|
// Not shared across API replicas; RATE_LIMIT_REPLICAS does not divide this limiter.
|
||
|
|
// Multi-replica hard caps need edge/WAF (or a future shared store).
|
||
|
|
type slidingLimiter struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
window time.Duration
|
||
|
|
limit int
|
||
|
|
hits map[string][]time.Time
|
||
|
|
lastGC time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
func newSlidingLimiter(limit int, window time.Duration) *slidingLimiter {
|
||
|
|
if limit <= 0 {
|
||
|
|
limit = 30
|
||
|
|
}
|
||
|
|
if window <= 0 {
|
||
|
|
window = time.Minute
|
||
|
|
}
|
||
|
|
return &slidingLimiter{
|
||
|
|
window: window,
|
||
|
|
limit: limit,
|
||
|
|
hits: make(map[string][]time.Time),
|
||
|
|
lastGC: time.Now(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l *slidingLimiter) allow(key string) bool {
|
||
|
|
now := time.Now()
|
||
|
|
cutoff := now.Add(-l.window)
|
||
|
|
l.mu.Lock()
|
||
|
|
defer l.mu.Unlock()
|
||
|
|
if now.Sub(l.lastGC) > l.window {
|
||
|
|
for k, ts := range l.hits {
|
||
|
|
kept := ts[:0]
|
||
|
|
for _, t := range ts {
|
||
|
|
if t.After(cutoff) {
|
||
|
|
kept = append(kept, t)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(kept) == 0 {
|
||
|
|
delete(l.hits, k)
|
||
|
|
} else {
|
||
|
|
l.hits[k] = kept
|
||
|
|
}
|
||
|
|
}
|
||
|
|
l.lastGC = now
|
||
|
|
}
|
||
|
|
ts := l.hits[key]
|
||
|
|
kept := ts[:0]
|
||
|
|
for _, t := range ts {
|
||
|
|
if t.After(cutoff) {
|
||
|
|
kept = append(kept, t)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(kept) >= l.limit {
|
||
|
|
l.hits[key] = kept
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
l.hits[key] = append(kept, now)
|
||
|
|
return true
|
||
|
|
}
|