// Package extsvc holds shared plumbing for the two independent external // services (Atomization, Work Performer): a small circuit breaker and a // retrying JSON HTTP helper. The services themselves stay fully independent // (§5) — each gets its own breaker instance, base URL, and token. package extsvc import ( "sync" "time" ) // Breaker implements §11.16: open after 3 consecutive failures, half-open // probe every 60 s. type Breaker struct { mu sync.Mutex fails int openedAt time.Time probing bool now func() time.Time threshold int cooldown time.Duration } func NewBreaker() *Breaker { return &Breaker{now: time.Now, threshold: 3, cooldown: 60 * time.Second} } // Allow reports whether a call may proceed right now. In the open state one // probe is allowed per cooldown window (half-open). func (b *Breaker) Allow() bool { b.mu.Lock() defer b.mu.Unlock() if b.fails < b.threshold { return true } if b.probing { return false } if b.now().Sub(b.openedAt) >= b.cooldown { b.probing = true // half-open: exactly one probe in flight return true } return false } // Record reports a call outcome. Success closes the breaker; failure counts // toward (or re-arms) the open state. func (b *Breaker) Record(err error) { b.mu.Lock() defer b.mu.Unlock() b.probing = false if err == nil { b.fails = 0 return } b.fails++ if b.fails >= b.threshold { b.openedAt = b.now() } } // State returns closed | open | half-open for status panels. func (b *Breaker) State() string { b.mu.Lock() defer b.mu.Unlock() if b.fails < b.threshold { return "closed" } if b.probing || b.now().Sub(b.openedAt) >= b.cooldown { return "half-open" } return "open" }