Bug-fix and performance release focused on the open/half-open state machine and rolling-window statistics. No breaking API changes (verified with gorelease; one additive method, SimpleBadRequest.Unwrap). Requires Go 1.24+ (CI covers 1.24, 1.25, 1.26).
Several fixes change observable behavior — see Behavior changes in v4.2 before upgrading, especially if you use ForceOpen, drive a hystrix.Closer directly in tests, or implement custom ClosedToOpen/OpenToClosed logic.
Bug fixes (#159)
Circuit state machine
ForceOpennow rejects every request; previously half-open probes still ran (one perSleepWindow, or every request with a permissive closer).OpenCircuit()/CloseCircuit()and automatic closes act on the underlying state whileForceOpen/ForcedClosedis set, instead of silently doing nothing.Metrics.Opened/Closedare delivered exactly once, strictly alternating and in transition order, without locks held — a listener that re-enters the circuit no longer deadlocks and a slow/panicking listener can't wedge later deliveries.ExecutionTimeoutis read once per run; a concurrentSetConfigThreadSafecould produce a deadline in the past and spuriousErrTimeouts that opened a healthy circuit.- An open circuit already at
MaxConcurrentRequestsshort-circuits without burning the closer's half-open permit. Execute/Run/Gowith a nilrunFuncno longer panic on nil/empty/Disabledcircuits.SetConfigNotThreadSafeon an open circuit informs the new open/close logic so the circuit can still recover.
closers/hystrix
CloserhonorsSleepWindow: probes are admitted only after the window armed byOpened(), and only requests that started after the circuit opened count toward closing it. Previously any in-flight success closed the circuit immediately and a stale in-flight failure could void a healthy probe.Closer.SetConfigThreadSafeno longer races onTimeAfterFunc.Opener.ShouldOpenuses integer math — error rates exactly on the threshold (e.g. 57/100 vs 57%) now trip.Opener.SetConfigNotThreadSafewith unsetNumBucketsno longer panics.
faststats
RollingBuckets.Advanceclears expired buckets before publishing the new index. The old lock-free walk let a burst of writers after an idle gap have their writes wiped, so the opener andRunStatsunder-counted the first requests after >1 window of quiet.RollingCounterderives its rolling sum from the buckets: never negative, one fewer contended atomic perInc. JSON output unchanged.RollingPercentilesnapshots no longer surface durations from an expired window.UnmarshalJSONrejects negative bucket state instead of panicking later; absolute bucket index isint64(wrapped after ~24 days of 1ms buckets on 32-bit).
Other
Manager.CreateCircuitchecks for duplicates before runningDefaultCircuitProperties(a failed duplicate create leakedrolling.StatFactoryentries) and no longer holds its lock while user constructors run (re-entrantGetCircuitdeadlocked).rollingRun/Fallback stats fill defaults instead of dividing by zero on partial configs.metriceventstreamsends headers immediately; with no circuits registered clients previously got no response.- expvar output keeps collectors whose
Varhas noValue()method. MockClock.Addis atomic and fires callbacks in deadline order.
Performance (#159, benchstat -cpu=8)
IsBadRequest: 75–150 ns + 1 alloc → 2–5 ns, 0 allocs.- Shed/error paths (open, failure+fallback, throttled fallback): −30…−48 %, 0 allocs (were 1–3).
- Parallel success path: −37…−45 % (per-request counters on their own cache line; no atomic RMW in
Closer.Successwhile closed). RollingPercentilesnapshot: −71 % time, 13 → 1 allocs.
Toolchain
- Go 1.24, 1.25 and 1.26 supported;
go.moddirective raised to 1.24 (#160). stretchr/testifyv1.12.0 (#158); CI action bumps (#156, #157).
Full changelog: v4.1.0...v4.2.0