// Package outbound bounds server-initiated network work — URL previews, // CardDAV syncs, IMAP test/check passes and manual signal checks — so an // authenticated user cannot open unbounded outbound connections, exhaust // file descriptors or saturate egress (review M-2). // // Two layers are shared by every caller: // - a global in-flight cap (defaultConcurrent), and // - a per-identity fixed-window rate (the caller supplies the limit/window, // since a preview is cheaper than an IMAP search). // // Exhaustion is immediate: previews degrade to no metadata and interactive // endpoints answer 429 with Retry-After. Nothing queues behind the budget. package outbound import ( "sync" "time" ) const defaultConcurrent = 8 type window struct { count int reset time.Time } type budget struct { mu sync.Mutex inFlight int max int keys map[string]*window } var shared = &budget{max: defaultConcurrent, keys: map[string]*window{}} // Reserve claims one outbound slot for key. The returned release MUST be // called when the work finishes. When ok is false, retry is a conservative // Retry-After duration. func Reserve(key string, limit int, windowDur time.Duration) (release func(), retry time.Duration, ok bool) { if key == "" { key = "anonymous" } shared.mu.Lock() defer shared.mu.Unlock() if shared.inFlight >= shared.max { return nil, time.Second, false } now := time.Now() if len(shared.keys) > 4096 { for k, w := range shared.keys { if now.After(w.reset) { delete(shared.keys, k) } } } w := shared.keys[key] if w == nil || now.After(w.reset) { w = &window{reset: now.Add(windowDur)} shared.keys[key] = w } if limit > 0 && w.count >= limit { left := w.reset.Sub(now) if left < 0 { left = 0 } return nil, left.Truncate(time.Second) + time.Second, false } w.count++ shared.inFlight++ return func() { shared.mu.Lock() shared.inFlight-- shared.mu.Unlock() }, 0, true }