fix: pin retry_after_s to the next certain retry moment
retry_after_s never had a written definition, so each backend improvised and they drifted apart. It now answers exactly one question: how long until a retry is *certainly* worth attempting. OPEN has such a moment (the cooldown deadline); HALF_OPEN does not, because the probe can come back at any time -- so it reports 0.0, which already means "retry now" elsewhere in the library. Six exits are brought in line. The half-open rejection is the one issue 14 reported: it returned the probe lease remainder, a deadlock-guard value derived from 2x the slowest timeout, so a 60s cooldown told callers to wait 600s. Worse, retry.py fed that number into the source cooldown memo, whose set_until only moves forward -- a source stayed skipped in-process for the whole lease even after its probe succeeded and the gate closed. That now writes an already-expired deadline, so the memo goes back to recording only real OPEN cooldowns. The other five were pre-existing memory/redis divergences hidden by a contract-test blind spot (the suite pinned that a second caller gets rejected, never what number it got): redis reported the probe TTL on grant and the lease remainder on fenced-out writes, where memory has always reported 0. Contract cases now pin all four half-open exits on both backends, with 1:1 real-wait variants for redis since the fake-clock ones skip there.
This commit is contained in:
@@ -292,6 +292,51 @@ class TestRetryAfter:
|
||||
assert await gate.retry_after_s(("s1", "s2")) == 0.0
|
||||
|
||||
|
||||
async def test_half_open_rejection_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""探针在途时被拒 → 0.0(issue #14): 探针随时可能出结果,不存在确定时刻。
|
||||
|
||||
旧行为返回探针租约剩余,而租约长度是**死锁保护参数**(派生自
|
||||
`2 × 最慢源 timeout`),与"这个源多久能恢复"没有因果关系。现场
|
||||
`TIMEOUT_S=300` 时它是 600s,而冷却期只有 60s。
|
||||
"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.is_probe
|
||||
blocked = await gate.try_enter("s1", "w2")
|
||||
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
|
||||
assert blocked.retry_after_s == 0.0
|
||||
|
||||
async def test_probe_grant_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""准入被允许 → 恒 0.0(现在就能试);此前 redis 侧返回探针 TTL。"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.allowed and probe.is_probe
|
||||
assert probe.retry_after_s == 0.0
|
||||
|
||||
async def test_retry_after_zero_while_probe_in_flight(self, gate_factory, clock):
|
||||
"""集合查询同口径: 探针在途的源不贡献等待时间。"""
|
||||
gate = gate_factory(_CFG)
|
||||
await _open_gate(gate)
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe
|
||||
assert await gate.retry_after_s(("s1",)) == 0.0
|
||||
|
||||
async def test_fenced_write_in_half_open_reports_no_certain_wait(self, gate_factory, clock):
|
||||
"""写回被 fencing 拒时的快照同口径;此前 redis 侧返回探针租约剩余。"""
|
||||
gate = gate_factory(_CFG)
|
||||
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
|
||||
await _open_gate(gate) # 他人开路,epoch 推进
|
||||
clock.advance(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
|
||||
update = await gate.record_success(stale)
|
||||
assert not update.applied and update.state is GateState.HALF_OPEN
|
||||
assert update.retry_after_s == 0.0
|
||||
|
||||
|
||||
class TestConsecutiveSuppression:
|
||||
"""迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。"""
|
||||
|
||||
|
||||
@@ -327,3 +327,49 @@ async def test_variant_probe_rate_limited_releases_not_hangs(redis_client):
|
||||
assert update.applied
|
||||
nxt = await gate.try_enter("s1", "w2")
|
||||
assert nxt.allowed and nxt.is_probe # 立即可再探,不等 probe_ttl
|
||||
|
||||
|
||||
# —— issue #14: retry_after_s = 距离**确定**可再试的时刻,HALF_OPEN 无确定时刻 ——
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_half_open_rejection_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.is_probe
|
||||
blocked = await gate.try_enter("s1", "w2")
|
||||
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
|
||||
assert blocked.retry_after_s == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_probe_grant_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
probe = await gate.try_enter("s1", "w1")
|
||||
assert probe.allowed and probe.is_probe
|
||||
assert probe.retry_after_s == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_retry_after_zero_while_probe_in_flight(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
await _open_gate(gate)
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe
|
||||
assert await gate.retry_after_s(("s1",)) == 0.0
|
||||
|
||||
|
||||
@pytestmark_slow
|
||||
async def test_variant_fenced_write_in_half_open_reports_no_certain_wait(redis_client):
|
||||
gate = _gate(redis_client)
|
||||
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
|
||||
await _open_gate(gate) # 他人开路,epoch 推进
|
||||
await asyncio.sleep(_CFG.cooldown_s + 1)
|
||||
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
|
||||
update = await gate.record_success(stale)
|
||||
assert not update.applied and update.state is GateState.HALF_OPEN
|
||||
assert update.retry_after_s == 0.0
|
||||
|
||||
Reference in New Issue
Block a user