diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py index d6c2729b8a..d7095a38b3 100644 --- a/agent/nous_rate_guard.py +++ b/agent/nous_rate_guard.py @@ -159,9 +159,13 @@ def welcome_refusal_from_headers( its body named) into the ``parse_welcome_refusal`` shape. Long wait or an empty bucket = ``rate_limited``; a short or unexplained one = ``at_capacity``. A missing wait stays 0 so the breaker never trips on a guess.""" - wait = _parse_reset_seconds(headers) or body_wait or 0.0 - empty = any(remaining is not None and remaining <= 0 - for remaining, _reset in _parse_buckets_from_headers(headers).values()) + buckets = _parse_buckets_from_headers(headers).values() + # Only an empty bucket's reset is a wait; a healthy bucket's reset is just its window end. + exhausted_wait = max((reset for remaining, reset in buckets + if remaining is not None and remaining <= 0 and (reset or 0) > 0), default=None) + wait = (exhausted_wait or parse_retry_after_seconds(lower_headers(headers).get("retry-after")) + or body_wait or 0.0) + empty = any(remaining is not None and remaining <= 0 for remaining, _reset in buckets) reason = "rate_limited" if wait >= WELCOME_LONG_WAIT_SECONDS or empty else "at_capacity" return {"reason": reason, "retry_after": max(0, int(wait)), "alternates": [], "upgrade_url": ""} diff --git a/tests/agent/test_free_tier_rate_limit_class.py b/tests/agent/test_free_tier_rate_limit_class.py index e40fadcb5d..6f21f5fd6b 100644 --- a/tests/agent/test_free_tier_rate_limit_class.py +++ b/tests/agent/test_free_tier_rate_limit_class.py @@ -9,7 +9,7 @@ import pytest from agent.error_classifier import classify_api_error from agent.error_surface import build_error_surface_from_result -from agent.nous_rate_guard import is_long_welcome_rate_limit +from agent.nous_rate_guard import is_long_welcome_rate_limit, welcome_refusal_from_headers from agent.turn_recovery import max_retries_exhausted_result, nonretryable_client_error_result from tests.hermes_cli.anon_portal import make_jwt @@ -44,6 +44,8 @@ CASES = [ ("retry-after-only", 429, {}, {"Retry-After": "900"}, "rate_limited", True), ("bucket-empty", 429, {}, {"x-ratelimit-remaining-requests-1h": "0", "x-ratelimit-reset-requests-1h": "3000"}, "rate_limited", True), + ("healthy-hourly-short-header", 429, {}, {"Retry-After": "4", "x-ratelimit-remaining-requests-1h": "750", + "x-ratelimit-reset-requests-1h": "2000"}, "at_capacity", False), ("short-header", 429, {}, {"Retry-After": "4"}, "at_capacity", False), ("bare", 429, {}, None, "at_capacity", False), ("unknown-reason", 429, {"reason": "something_else", "retry_after": 5}, None, "at_capacity", False), @@ -87,3 +89,11 @@ def test_named_account_same_response_is_not_a_free_tier_refusal(case_id, status, result = _result(agent, error, classified, status) assert "free_tier" not in result assert "To sign in: /login." not in result["final_response"] + + +@pytest.mark.parametrize("remaining,reason,retry_after", [("750", "at_capacity", 4), ("0", "rate_limited", 2000)]) +def test_header_wait_is_the_exhausted_bucket_reset_else_retry_after(remaining, reason, retry_after): + headers = {"Retry-After": "4", "x-ratelimit-remaining-requests-1h": remaining, + "x-ratelimit-reset-requests-1h": "2000"} + refusal = welcome_refusal_from_headers(headers) + assert (refusal["reason"], refusal["retry_after"]) == (reason, retry_after)