diff options
Diffstat (limited to 'internal/apicircuit/apicircuit.go')
| -rw-r--r-- | internal/apicircuit/apicircuit.go | 85 |
1 files changed, 62 insertions, 23 deletions
diff --git a/internal/apicircuit/apicircuit.go b/internal/apicircuit/apicircuit.go index 05081e5..7f623cb 100644 --- a/internal/apicircuit/apicircuit.go +++ b/internal/apicircuit/apicircuit.go @@ -1,10 +1,12 @@ -// Package apicircuit wraps outbound Gemini API calls with sony/gobreaker -// circuit breakers so repeated failures do not pile up unbounded work. +// Package apicircuit wraps outbound API calls with circuit breakers so repeated +// failures against any provider do not pile up unbounded work. package apicircuit import ( "context" "errors" + "fmt" + "strings" "sync" "time" @@ -12,6 +14,15 @@ import ( ) const ( + // CapabilityText identifies text-generation traffic. + CapabilityText Capability = "text" + // CapabilityImage identifies image-generation traffic. + CapabilityImage Capability = "image" + // CapabilityTTS identifies text-to-speech traffic. + CapabilityTTS Capability = "tts" +) + +const ( // breakerInterval clears rolling failure counts in the closed state so stale // errors do not keep the breaker sensitive forever. breakerInterval = 2 * time.Minute @@ -24,12 +35,22 @@ const ( breakerTripAfterConsecutiveFailures uint32 = 5 ) -var ( - geminiTTSOnce sync.Once - geminiTTSBreaker *gobreaker.CircuitBreaker - geminiImageOnce sync.Once - geminiImageBreaker *gobreaker.CircuitBreaker -) +// Capability identifies the class of API call protected by a circuit breaker. +type Capability string + +// Registry stores lazily-created circuit breakers keyed by provider and capability. +type Registry struct { + mu sync.Mutex + breakers map[string]*gobreaker.CircuitBreaker +} + +// NewRegistry creates an empty breaker registry. +func NewRegistry() *Registry { + return &Registry{breakers: make(map[string]*gobreaker.CircuitBreaker)} +} + +// DefaultRegistry is used by package-level helpers. +var DefaultRegistry = NewRegistry() // isSuccessful counts only real API outcomes: nil is success; context.Canceled is // treated as success so user abort does not trip the breaker. Timeouts and @@ -57,18 +78,36 @@ func newBreaker(name string) *gobreaker.CircuitBreaker { }) } -func geminiBreaker(name string, slot **gobreaker.CircuitBreaker, once *sync.Once) *gobreaker.CircuitBreaker { - once.Do(func() { - *slot = newBreaker(name) - }) +func breakerKey(providerName string, capability Capability) string { + return NormalizeName(providerName) + ":" + NormalizeName(string(capability)) +} + +func (r *Registry) breaker(providerName string, capability Capability) *gobreaker.CircuitBreaker { + key := breakerKey(providerName, capability) + + r.mu.Lock() + defer r.mu.Unlock() - return *slot + if r.breakers == nil { + r.breakers = make(map[string]*gobreaker.CircuitBreaker) + } + if breaker := r.breakers[key]; breaker != nil { + return breaker + } + + breaker := newBreaker(key) + r.breakers[key] = breaker + return breaker } -func runValue[T any](cb *gobreaker.CircuitBreaker, fn func() (T, error)) (T, error) { +// Execute runs fn through the breaker for providerName and capability. +func Execute[T any](r *Registry, providerName string, capability Capability, fn func() (T, error)) (T, error) { var zero T + if r == nil { + r = DefaultRegistry + } - v, err := cb.Execute(func() (interface{}, error) { + v, err := r.breaker(providerName, capability).Execute(func() (interface{}, error) { return fn() }) if err != nil { @@ -78,15 +117,15 @@ func runValue[T any](cb *gobreaker.CircuitBreaker, fn func() (T, error)) (T, err return zero, nil } - return v.(T), nil -} + result, ok := v.(T) + if !ok { + return zero, fmt.Errorf("unexpected breaker result type for %s", breakerKey(providerName, capability)) + } -// GeminiTTS runs one Gemini TTS GenerateContent call through its circuit breaker. -func GeminiTTS[T any](fn func() (T, error)) (T, error) { - return runValue(geminiBreaker("gemini-tts", &geminiTTSBreaker, &geminiTTSOnce), fn) + return result, nil } -// GeminiImage runs one Gemini image-generation call through its circuit breaker. -func GeminiImage[T any](fn func() (T, error)) (T, error) { - return runValue(geminiBreaker("gemini-image", &geminiImageBreaker, &geminiImageOnce), fn) +// NormalizeName returns a canonical lower-case name for breaker keys. +func NormalizeName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) } |
