feat(registry): add per-provider discovery circuit breaker

Add a three-state circuit breaker to providerState: closed, open, and
half-open. It is driven only by discovery-refresh outcomes, never by
request-path traffic.

A provider whose refresh fails breakerThreshold times in a row opens
the breaker and backs off before its next half-open probe, instead of
retrying every discovery interval forever. A failed probe reopens the
breaker with a doubled backoff, capped at breakerOpenMax. A successful
probe closes the breaker again, so a recovered provider never stays
dead.

The failure classification reuses limits.go's isTransient and
isDeadlineExceeded, the same rule recordProviderAttempt already
applies to request-path attempts. It does not add a second
classification.

Add Config.Breaker (BreakerConfig), validated and defaulted by
validateBreakerConfig. Every default preserves current behavior for a
provider that never fails: a closed breaker never changes the existing
interval-based refresh gating.

Export modelRegistry.providerUsable(name) as the queryable health
predicate. A sibling branch (feat/failover) will use it to skip
unusable providers before routing. This commit does not touch the
request path or routes_unified.go.
This commit is contained in:
2026-08-22 22:13:52 +01:00
parent 9dc67dedbb
commit e9ce6b873d
4 changed files with 771 additions and 45 deletions
+1 -1
View File
@@ -367,7 +367,7 @@ func TestModelRegistry_RefreshProvider_MetadataPanicDoesNotFailDiscovery(t *test
if !reg.states["lmstudio"].hasModel("m2") {
t.Error("a panic in metadata capture must not discard the successful discovery result")
}
_, _, lastErr := reg.states["lmstudio"].snapshot()
_, _, lastErr, _, _ := reg.states["lmstudio"].snapshot()
if lastErr != "" {
t.Errorf("lastErr = %q, want empty — discovery itself succeeded; only metadata capture panicked", lastErr)
}