fix(refresh): reset window to zero attempts and admit one half-open probe

The window and cooldown resets set attempts to 1. The gate then counted
the same call again, so a reset window allowed one call fewer than a
fresh one. With a limit of 1 the session locked out at once. Resets now
start at 0.

The half-open breaker admitted every caller. It now admits one probe,
claimed with a compare-and-swap. A stale probe is replaced after
OpenDuration, so a lost probe cannot wedge the breaker.
This commit is contained in:
2026-11-03 00:07:15 +00:00
parent 29a9b1f8f2
commit dae08b2c21
2 changed files with 111 additions and 11 deletions
+18 -11
View File
@@ -672,7 +672,6 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
return &attemptState{
windowStartNano: nowNano,
attempts: 1,
}
})
return false
@@ -686,7 +685,7 @@ func (rc *RefreshCoordinator) isInCooldown(sessionID string) bool {
}
next := *s
next.windowStartNano = nowNano
next.attempts = 1
next.attempts = 0
return &next
})
return false
@@ -939,20 +938,28 @@ func (cb *RefreshCircuitBreaker) AllowRequest() bool {
return true
case 1: // open
lastFail := atomic.LoadInt64(&cb.lastFailureNano)
if time.Duration(time.Now().UnixNano()-lastFail) > cb.config.OpenDuration {
// Transition to half-open; first CAS winner gets the probe.
if atomic.CompareAndSwapInt32(&cb.state, 1, 2) {
return true
}
}
return false
case 2: // half-open
return true
return cb.claimProbe(lastFail)
case 2: // half-open: one probe at a time; a stale probe is replaced
return cb.claimProbe(atomic.LoadInt64(&cb.lastFailureNano))
default:
return false
}
}
// claimProbe admits one probe when OpenDuration has passed since lastFail.
// The CAS on lastFailureNano makes the claim exclusive and restarts the clock.
func (cb *RefreshCircuitBreaker) claimProbe(lastFail int64) bool {
now := time.Now().UnixNano()
if time.Duration(now-lastFail) <= cb.config.OpenDuration {
return false
}
if !atomic.CompareAndSwapInt64(&cb.lastFailureNano, lastFail, now) {
return false
}
atomic.StoreInt32(&cb.state, 2)
return true
}
// RecordSuccess records a successful operation. Lock-free.
func (cb *RefreshCircuitBreaker) RecordSuccess() {
switch atomic.LoadInt32(&cb.state) {