fix(free-tier): a short free-tier 429 no longer blocks for a whole quota window

welcome_refusal_from_headers took the hourly bucket's reset as the wait
even when that bucket had requests left, so Retry-After: 4 with a
healthy bucket became a 2,000 s rate_limited refusal and tripped the
shared breaker. The wait now comes from an empty bucket's reset, else
Retry-After, else the body's wait.
This commit is contained in:
alt-glitch
2026-10-07 19:22:43 +05:30
parent 3531ae5e93
commit 16f16755d5
2 changed files with 18 additions and 4 deletions

View File

@@ -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": ""}

View File

@@ -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)