diff options
Diffstat (limited to 'internal/llm/circuitbreaker.go')
| -rw-r--r-- | internal/llm/circuitbreaker.go | 139 |
1 files changed, 139 insertions, 0 deletions
diff --git a/internal/llm/circuitbreaker.go b/internal/llm/circuitbreaker.go new file mode 100644 index 0000000..f7014de --- /dev/null +++ b/internal/llm/circuitbreaker.go @@ -0,0 +1,139 @@ +package llm + +import ( + "sync" + "time" +) + +// circuitState models the three states of the classic circuit-breaker pattern. +// +// - closed: normal operation, requests flow through. +// - open: too many consecutive failures occurred; requests are rejected +// immediately for a cooldown window to give the upstream time to recover. +// - halfOpen: the cooldown elapsed; a single trial request is allowed through +// to probe whether the upstream has recovered. +type circuitState int + +const ( + circuitClosed circuitState = iota + circuitOpen + circuitHalfOpen +) + +// circuitBreaker is a small, dependency-free circuit breaker used to protect +// the LLM HTTP call path. It trips open after a configurable number of +// consecutive failures and stays open for a cooldown window, after which it +// allows a single trial ("half-open") request. A success in any state resets +// the breaker to closed. +// +// The breaker only counts failures that the retry layer deemed transient +// (network errors, 5xx, 429). Client errors (4xx) are not failures from the +// breaker's perspective: they indicate a bad request, not an unhealthy +// upstream, so they must not trip the circuit. +// +// A nil *circuitBreaker is a valid no-op breaker: Allow always returns true and +// the record* methods do nothing. This lets callers disable the breaker simply +// by passing nil. +type circuitBreaker struct { + mu sync.Mutex + + // threshold is the number of consecutive failures that trips the breaker. + threshold int + // cooldown is how long the breaker stays open before allowing a trial. + cooldown time.Duration + // now is injectable for deterministic tests; defaults to time.Now. + now func() time.Time + + state circuitState + failures int + openedAt time.Time + probeRunning bool +} + +// newCircuitBreaker returns a breaker that trips after `threshold` consecutive +// transient failures and stays open for `cooldown`. A threshold <= 0 disables +// tripping (the breaker stays closed forever), which callers can use to turn +// the breaker off without special-casing nil. +func newCircuitBreaker(threshold int, cooldown time.Duration) *circuitBreaker { + return &circuitBreaker{ + threshold: threshold, + cooldown: cooldown, + now: time.Now, + state: circuitClosed, + } +} + +// Allow reports whether a request may proceed under the current breaker state. +// When the breaker is open and the cooldown has elapsed it transitions to +// half-open and permits exactly one trial request; concurrent callers during +// half-open are rejected until the trial resolves via recordSuccess/recordFailure. +func (cb *circuitBreaker) Allow() bool { + if cb == nil { + return true + } + cb.mu.Lock() + defer cb.mu.Unlock() + + switch cb.state { + case circuitClosed: + return true + case circuitOpen: + if cb.now().Sub(cb.openedAt) < cb.cooldown { + return false + } + // Cooldown elapsed: move to half-open and allow a single probe. + cb.state = circuitHalfOpen + cb.probeRunning = true + return true + case circuitHalfOpen: + // Only one probe at a time while half-open. + if cb.probeRunning { + return false + } + cb.probeRunning = true + return true + default: + return true + } +} + +// recordSuccess resets the breaker to its healthy (closed) state. A success +// from a half-open probe means the upstream recovered. +func (cb *circuitBreaker) recordSuccess() { + if cb == nil { + return + } + cb.mu.Lock() + defer cb.mu.Unlock() + cb.state = circuitClosed + cb.failures = 0 + cb.probeRunning = false +} + +// recordFailure registers a transient failure. While half-open it re-opens the +// breaker immediately (the probe failed). While closed it trips the breaker +// once the consecutive-failure count reaches the threshold. +func (cb *circuitBreaker) recordFailure() { + if cb == nil { + return + } + cb.mu.Lock() + defer cb.mu.Unlock() + cb.probeRunning = false + + if cb.state == circuitHalfOpen { + cb.trip() + return + } + cb.failures++ + if cb.threshold > 0 && cb.failures >= cb.threshold { + cb.trip() + } +} + +// trip moves the breaker to the open state and stamps the open time. Callers +// must hold cb.mu. +func (cb *circuitBreaker) trip() { + cb.state = circuitOpen + cb.openedAt = cb.now() +} |
