feat: add AIMD adaptive concurrency pacing per source

P6 round 2 showed routing convergence turns account-level 429s into
the binding constraint (62% throttle rate at full concurrency). Each
source now carries a local AIMD limit: multiplicative cut on 429,
additive growth on success. Over-limit picks queue via the existing
quota-wait poll instead of burning retry budget or tripping the
circuit-open verdict.
This commit is contained in:
2026-07-21 10:38:48 -04:00
parent 910857fd13
commit 58061c7536
6 changed files with 157 additions and 2 deletions
+48
View File
@@ -98,6 +98,7 @@ def _harness(
global_limits=_NO_GLOBAL,
rng=lambda: 0.0,
selector=None,
pacer=None,
):
clock = clock or FakeClock()
limiter = InMemoryLimiter(
@@ -125,6 +126,7 @@ def _harness(
now=clock,
sleep=sleep,
rng=rng,
pacer=pacer,
)
return mw, limiter, gate, transport, sleep, clock
@@ -443,3 +445,49 @@ class TestM25Orchestration:
await mw(_REQ)
g = gate._gates["a"]
assert g.a0 + g.a1 == 0
class TestAdaptivePacing:
"""AIMD 接线(设计 §3.35): 429 收紧准入,paced 源等待而非烧预算。"""
async def test_paced_source_waits_without_consuming_budget(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
for _ in range(200):
pacer.on_backpressure("a") # limit → 1
pacer.enter("a") # 模拟一个在途占满名额
mw, _, _, transport, _, _ = _harness(
[_src("a")], [_ok()], quota_full="fail_fast", pacer=pacer
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "quota_exhausted" # 走配额等待通道,非 CircuitOpen
assert ei.value.per_source_reasons.get("a") == "adaptive_paced"
assert transport.calls == [] # 未发起尝试 → 不烧重试预算
async def test_429_cuts_limit_success_grows_it(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
mw, _, _, _, _, _ = _harness(
[_src("a")],
[TransientError("throttled", status_code=429), _ok()],
pacer=pacer,
)
await mw(_REQ)
# 429 削减一次(8→5.6),随后成功加性增长(5.6 + 1/5.6)
assert pacer.limit("a") == pytest.approx(8.0 * 0.7 + 1.0 / (8.0 * 0.7))
async def test_inflight_returns_to_zero_after_call(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
mw, _, _, _, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("x"), _ok()],
pacer=pacer,
)
await mw(_REQ)
assert pacer._inflight.get("a", 0) == 0
assert pacer._inflight.get("b", 0) == 0