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:
@@ -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": ""}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user