package vip.mate.llm.failover; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; /** * RFC-009 P3.3: per-provider health tracker for the multi-model failover chain. * *
Tracks consecutive failure counts per provider id. When a provider hits * {@link ProviderHealthProperties#getFailureThreshold} consecutive failures, * it enters a cooldown window of {@link ProviderHealthProperties#getCooldownMs} * milliseconds during which the chain walker skips it entirely. A successful * call resets the counter and clears the cooldown immediately.
* *Without this guard, a provider whose API key has been revoked or whose * service is degraded would be re-tried on every conversation turn, eating * the full retry budget and adding seconds of latency before the chain * advances. With it, the first round trips through the broken provider; the * next N turns skip it instantly and try the next entry directly.
* *State is in-memory only — process restart resets all counters. That is * intentional for v1: per-process tracking is enough for desktop / single-node * deployments, and avoids the complication of distributed coordination. A * future RFC may upgrade this to a {@code mate_provider_health} row.
*/ @Slf4j @Component @Configuration @EnableConfigurationProperties(ProviderHealthProperties.class) public class ProviderHealthTracker { private final ProviderHealthProperties props; private final ConcurrentHashMapWhen {@code cooldownOverrideMs > 0} the provider has stated exactly * when capacity returns, so the cooldown starts immediately — * waiting for {@link ProviderHealthProperties#getFailureThreshold} more * consecutive failures would burn extra calls against a window the * provider already announced. The override is clamped to * {@link #MAX_COOLDOWN_OVERRIDE_MS} and never shortens an active * cooldown.
*/ public void recordFailure(String providerId, long cooldownOverrideMs) { if (!props.isEnabled() || providerId == null) return; AtomicLong counter = consecutiveFailures.computeIfAbsent(providerId, k -> new AtomicLong()); long failures = counter.incrementAndGet(); if (cooldownOverrideMs > 0) { long clamped = Math.min(cooldownOverrideMs, MAX_COOLDOWN_OVERRIDE_MS); long cooldownEnd = System.currentTimeMillis() + clamped; cooldownUntilMs.merge(providerId, cooldownEnd, Math::max); log.warn("[ProviderHealth] provider={} entering cooldown for {}s (provider-stated retry window)", providerId, clamped / 1000); return; } if (failures >= props.getFailureThreshold()) { long cooldownEnd = System.currentTimeMillis() + props.getCooldownMs(); cooldownUntilMs.put(providerId, cooldownEnd); log.warn("[ProviderHealth] provider={} hit {} consecutive failures, entering cooldown for {}s", providerId, failures, props.getCooldownMs() / 1000); } } /** Successful call: reset the failure counter and clear any active cooldown. */ public void recordSuccess(String providerId) { if (!props.isEnabled() || providerId == null) return; AtomicLong counter = consecutiveFailures.get(providerId); if (counter != null) counter.set(0); if (cooldownUntilMs.remove(providerId) != null) { log.info("[ProviderHealth] provider={} recovered, cooldown cleared", providerId); } } /** * Diagnostic snapshot for admin endpoints / tests. Returns a stable view * with provider id → (failures, cooldown remaining ms; 0 if not in cooldown). */ public Map