fix(backoff): keep 60s first-hit cooldown, escalate only on consecutive rate-limits

Review follow-up on the #30223 salvage: the original changed the base
cooldown from 60s to 1800s, benching the primary for 30 minutes on the
FIRST 429 (30x regression in primary-restore latency) and breaking the
existing test_rate_limit_exhaustion_keeps_60s_cooldown contract.

Keep upstream's 60s base and escalate per consecutive rate-limit:
60s -> 2m -> 4m -> 8m -> ... capped at 4h. Counter still resets on
successful primary restore (cicae's mechanism, unchanged).

New tests: escalation doubling, 14400s cap, reset-on-restore.
Existing 60s contract test passes UNCHANGED. Mutation-checked:
escalation disabled -> 2 fail; reset disabled -> 1 fails.
This commit is contained in:
kshitij 2026-08-03 16:23:55 +05:30
parent 9267c7823c
commit df9dbba2ba
2 changed files with 106 additions and 2 deletions

View File

@ -1712,11 +1712,15 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
primary_provider = ((agent._primary_runtime or {}).get("provider") or "").strip().lower()
if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):
# Exponential backoff: 30min → 1h → 2h → 4h cap
# Exponential backoff: keep upstream's 60s first-hit cooldown and
# escalate on CONSECUTIVE rate-limits: 60s → 2m → 4m → 8m → ... →
# 4h cap. The first 429 must NOT bench the primary for half an
# hour — fast primary restore is the common case; escalation only
# punishes providers that keep 429ing.
# Counter is reset by restore_primary_runtime on successful restore.
backoff_count = getattr(agent, "_rate_limit_backoff_count", 0)
agent._rate_limit_backoff_count = backoff_count + 1
backoff_seconds = min(1800 * (2 ** backoff_count), 14400)
backoff_seconds = min(60 * (2 ** backoff_count), 14400)
agent._rate_limited_until = time.monotonic() + backoff_seconds
logging.info(
"Rate-limit backoff level %d: cooldown %d s (%.1f min, backoff#%d)",

View File

@ -130,3 +130,103 @@ class TestExhaustionArmsCooldown:
assert agent._try_activate_fallback() is False
cooldown = getattr(agent, "_rate_limited_until", 0)
assert cooldown == far_future
class TestRateLimitBackoffEscalation:
"""Exponential backoff for consecutive rate-limit failures (#29702).
The first rate-limit keeps the historical 60s cooldown; each consecutive
rate-limit within one degradation window doubles it (60 → 120 → 240 → ...)
capped at 4h (14400s). A successful primary restore resets the counter.
"""
@staticmethod
def _back_on_primary(agent, snapshot):
"""Simulate the primary provider rate-limiting again on a later turn
(without a successful restore, which would reset the counter): put
the agent's identity back on the primary and reset the turn-scoped
fallback chain state."""
agent.provider, agent.model, agent.base_url = snapshot
agent._fallback_activated = False
agent._fallback_index = 0
def test_backoff_doubles_per_consecutive_rate_limit(self):
"""Each consecutive primary rate-limit doubles the cooldown:
60s, then 120s, then 240s."""
fbs = [{"provider": "openai", "model": "gpt-4o"}]
agent = _make_agent(fallback_model=fbs)
agent._rate_limited_until = 0
snapshot = (agent.provider, agent.model, agent.base_url)
frozen = 1_000.0
with (
patch("agent.chat_completion_helpers.time.monotonic", return_value=frozen),
patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(_mock_client(), "resolved"),
),
):
expected = [60, 120, 240]
for n, want in enumerate(expected):
self._back_on_primary(agent, snapshot)
agent._try_activate_fallback(reason=FailoverReason.rate_limit)
assert agent._rate_limited_until == frozen + want, (
f"backoff #{n + 1}: expected {want}s cooldown"
)
assert agent._rate_limit_backoff_count == n + 1
def test_backoff_caps_at_four_hours(self):
"""Escalation is capped at 14400s (4h) no matter how many
consecutive rate-limits occurred."""
fbs = [{"provider": "openai", "model": "gpt-4o"}]
agent = _make_agent(fallback_model=fbs)
agent._rate_limited_until = 0
# 60 * 2**10 = 61440s, far past the cap.
agent._rate_limit_backoff_count = 10
frozen = 1_000.0
with (
patch("agent.chat_completion_helpers.time.monotonic", return_value=frozen),
patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(_mock_client(), "resolved"),
),
):
agent._try_activate_fallback(reason=FailoverReason.rate_limit)
assert agent._rate_limited_until == frozen + 14400
def test_backoff_counter_resets_on_successful_primary_restore(self):
"""A successful restore_primary_runtime resets the backoff counter,
so the next rate-limit starts back at the 60s base."""
fbs = [{"provider": "openai", "model": "gpt-4o"}]
agent = _make_agent(fallback_model=fbs)
snapshot = (agent.provider, agent.model, agent.base_url)
frozen = 1_000.0
with (
patch("agent.chat_completion_helpers.time.monotonic", return_value=frozen),
patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(_mock_client(), "resolved"),
),
):
# Two consecutive rate-limits escalate the counter to 2.
agent._rate_limited_until = 0
agent._try_activate_fallback(reason=FailoverReason.rate_limit)
self._back_on_primary(agent, snapshot)
agent._try_activate_fallback(reason=FailoverReason.rate_limit)
assert agent._rate_limit_backoff_count == 2
# Cooldown expired; the primary restores successfully.
agent._fallback_activated = True
agent._rate_limited_until = 0
assert agent._restore_primary_runtime() is True
assert agent._rate_limit_backoff_count == 0
# The next rate-limit is treated as a fresh first failure: 60s.
with (
patch("agent.chat_completion_helpers.time.monotonic", return_value=frozen),
patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(_mock_client(), "resolved"),
),
):
agent._try_activate_fallback(reason=FailoverReason.rate_limit)
assert agent._rate_limited_until == frozen + 60