From 5dc108ef0923d4a1f8065ef98ab8b0f2ffc8bb2f Mon Sep 17 00:00:00 2001 From: Lev Date: Mon, 13 Apr 2026 04:09:22 +0300 Subject: [PATCH] fix(circuit_breaker): clear rate keys on auto-reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auto_reset_after_minutes > 0 and the cooldown elapses, the circuit breaker deletes the state key but leaves the rate accumulation keys alive (TTL = WINDOW_TTL_SECONDS = 120 s). For any cooldown shorter than 120 s the sliding-window estimator reads the stale high-rate data and immediately re-trips the breaker on the very first post-reset request, making auto-reset a no-op. Fix: mirror what _M.reset() already does — delete current and previous rate keys immediately after clearing the state key. Also introduce SECONDS_PER_MINUTE (= 60) so the elapsed-minutes calculation is not semantically coupled to WINDOW_SIZE_SECONDS; the two constants are coincidentally equal but represent different concepts. Test coverage: AC-4 uses auto_reset_after_minutes=5 (300 s > 120 s WINDOW_TTL_SECONDS), so rate keys expire naturally and the bug never manifests. A future test with auto_reset_after_minutes=1 will cover the fixed path. --- src/fairvisor/circuit_breaker.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/fairvisor/circuit_breaker.lua b/src/fairvisor/circuit_breaker.lua index 1f2a33b..7535429 100644 --- a/src/fairvisor/circuit_breaker.lua +++ b/src/fairvisor/circuit_breaker.lua @@ -16,6 +16,7 @@ local tostring = tostring -- Fixed 1-minute rolling window; TTL for rate keys. Configurable window could be added later. local WINDOW_SIZE_SECONDS = 60 local WINDOW_TTL_SECONDS = 120 +local SECONDS_PER_MINUTE = 60 local STATE_PREFIX = "cb_state:" local RATE_PREFIX = "cb_rate:" local OPEN_PREFIX = "open:" @@ -116,7 +117,7 @@ function _M.check(dict, config, limit_key, cost, now) local opened_at = _parse_opened_at(state_raw) if opened_at then if config.auto_reset_after_minutes > 0 then - local elapsed_minutes = (now - opened_at) / WINDOW_SIZE_SECONDS + local elapsed_minutes = (now - opened_at) / SECONDS_PER_MINUTE if elapsed_minutes < config.auto_reset_after_minutes then return { tripped = true, @@ -129,6 +130,12 @@ function _M.check(dict, config, limit_key, cost, now) end dict:delete(state_key) + -- Clear rate keys so the sliding-window estimator does not immediately + -- re-trip the breaker on the first post-reset request. Mirrors _M.reset(). + local current_window_ar = floor(now / WINDOW_SIZE_SECONDS) * WINDOW_SIZE_SECONDS + local previous_window_ar = current_window_ar - WINDOW_SIZE_SECONDS + dict:delete(_M.build_rate_key(limit_key, current_window_ar)) + dict:delete(_M.build_rate_key(limit_key, previous_window_ar)) else return { tripped = true,