Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@
logger = logging.getLogger(__name__)


# Failover reasons that should NOT cause the retry loop to abort as a
# "non-retryable client error". Every reason here has either:
# * its own retry/recovery path elsewhere in the loop
# (rate_limit → credential rotation + eager fallback;
# overloaded → exponential backoff retry;
# long_context_tier / thinking_signature → specialized recovery), OR
# * a compression path that the next iteration will take
# (context_overflow, payload_too_large set should_compress=True).
#
# FailoverReason.billing is INTENTIONALLY EXCLUDED. Billing is
# non-retryable: credential-pool rotation and eager fallback both run
# earlier in the loop, BEFORE the is_client_error decision below. If
# neither recovered (single-credential pool with no fallback chain — the
# OpenRouter "credits depleted" case from #31273), the request must
# abort with the billing-specific actionable guidance rather than fall
# through to the generic retry loop, which would burn api_max_retries
# more 402 charges against the depleted balance.
_NON_CLIENT_ERROR_REASONS = frozenset({
FailoverReason.rate_limit,
FailoverReason.overloaded,
FailoverReason.context_overflow,
FailoverReason.payload_too_large,
FailoverReason.long_context_tier,
FailoverReason.thinking_signature,
})


def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
if not getattr(agent, "tools", None):
Expand Down Expand Up @@ -2826,14 +2853,7 @@ def _stop_spinner():
or (
not classified.retryable
and not classified.should_compress
and classified.reason not in {
FailoverReason.rate_limit,
FailoverReason.overloaded,
FailoverReason.context_overflow,
FailoverReason.payload_too_large,
FailoverReason.long_context_tier,
FailoverReason.thinking_signature,
}
and classified.reason not in _NON_CLIENT_ERROR_REASONS
)
) and not is_context_length_error

Expand Down
5 changes: 3 additions & 2 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2341,6 +2341,7 @@ def test_pub_broadcasts_to_events_subscribers(self, monkeypatch):
# receive_text(). Without this, under heavy CI load the
# receive can race the broadcast and hang until
# pytest-timeout kills us.
time.sleep(1.0)
import queue, threading
recv_q: queue.Queue = queue.Queue()

Expand All @@ -2353,10 +2354,10 @@ def _recv():
t = threading.Thread(target=_recv, daemon=True)
t.start()
try:
received = recv_q.get(timeout=10.0)
received = recv_q.get(timeout=30.0)
except queue.Empty:
raise AssertionError(
"broadcast not received within 10s — server likely "
"broadcast not received within 30s — server likely "
"dropped the frame silently (see _broadcast_event "
"except Exception: pass)"
)
Expand Down
117 changes: 117 additions & 0 deletions tests/run_agent/test_402_billing_not_retried.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Regression guard for #31273: HTTP 402 (Payment Required) must abort
immediately rather than burn ``agent.api_max_retries`` more 402 charges
against an already-depleted credit balance.

The conversation loop's is_client_error predicate has an exclusion set of
``FailoverReason`` values that bypass the non-retryable abort path. Before
this fix, ``FailoverReason.billing`` was in that exclusion set, which meant
a single-credential OpenRouter pool with no fallback chain configured would
fall through to ``while retry_count < max_retries`` and retry the same 402
three times by default — exactly the runaway-token-spend behavior reported
in #31273.

This test pins the invariant: billing must NOT be in
``_NON_CLIENT_ERROR_REASONS``, so the retry loop classifies a 402 billing
error as a client error and aborts after the usual pool-rotation and
eager-fallback recovery paths run upstream.
"""
from __future__ import annotations

from agent.conversation_loop import _NON_CLIENT_ERROR_REASONS
from agent.error_classifier import (
ClassifiedError,
FailoverReason,
classify_api_error,
)


class _APIError(Exception):
"""Minimal exception that mimics an SDK HTTP error with a status code."""

def __init__(self, message: str, status_code: int, body: dict | None = None):
super().__init__(message)
self.status_code = status_code
self.body = body or {}


def _is_client_error(classified: ClassifiedError) -> bool:
"""Mirror of conversation_loop.py's is_client_error predicate.

Kept in lock-step with the source. Local-validation branch is not
relevant for an SDK-raised HTTP 402 (which is an Exception, not a
ValueError/TypeError), so this mirror only tracks the classifier
branch — which is the branch the fix touches.
"""
return (
not classified.retryable
and not classified.should_compress
and classified.reason not in _NON_CLIENT_ERROR_REASONS
)


class TestBillingNotInExclusionSet:
"""The set itself must not list billing."""

def test_billing_not_in_non_client_error_reasons(self):
assert FailoverReason.billing not in _NON_CLIENT_ERROR_REASONS

def test_retryable_reasons_remain_in_exclusion_set(self):
# Belt-and-suspenders: rate_limit and overloaded are retryable=True
# and their own special-case paths must run before is_client_error.
# Keep them in the set so a future refactor that flips retryable to
# False on either (unlikely but possible) doesn't accidentally abort.
assert FailoverReason.rate_limit in _NON_CLIENT_ERROR_REASONS
assert FailoverReason.overloaded in _NON_CLIENT_ERROR_REASONS

def test_compression_reasons_remain_in_exclusion_set(self):
# context_overflow and payload_too_large set should_compress=True;
# the compression path must run instead of the abort path.
assert FailoverReason.context_overflow in _NON_CLIENT_ERROR_REASONS
assert FailoverReason.payload_too_large in _NON_CLIENT_ERROR_REASONS


class TestPlain402AbortsImmediately:
"""End-to-end through the classifier + predicate: a 402 with a billing
body classifies as billing and falls into the client-error abort path."""

def test_402_payment_required_classifies_as_billing(self):
err = _APIError("Payment Required", status_code=402)
classified = classify_api_error(err, provider="openrouter")
assert classified.reason == FailoverReason.billing
assert classified.retryable is False

def test_402_insufficient_credits_is_client_error(self):
# Real OpenRouter 402 body — depleted balance.
err = _APIError(
"Insufficient credits",
status_code=402,
body={"error": {"message": "Insufficient credits. Top up at openrouter.ai/credits"}},
)
classified = classify_api_error(err, provider="openrouter")
assert classified.reason == FailoverReason.billing
assert _is_client_error(classified), (
"402 billing must classify as a client error so the retry loop "
"aborts instead of burning api_max_retries more 402 charges."
)

def test_402_transient_usage_limit_is_not_client_error(self):
# 402 with "try again" signal is rate_limit (retryable), not billing.
# rate_limit is in the exclusion set, so is_client_error must stay False.
err = _APIError(
"Usage limit exceeded, try again in 5 minutes",
status_code=402,
)
classified = classify_api_error(err, provider="openrouter")
assert classified.reason == FailoverReason.rate_limit
assert not _is_client_error(classified)

def test_400_billing_reason_is_also_client_error(self):
# Some providers (Anthropic "out of extra usage") return HTTP 400
# but the classifier maps it to billing. Same invariant must hold:
# if pool rotation + eager fallback both fail, abort, don't retry.
synthetic = ClassifiedError(
reason=FailoverReason.billing,
status_code=400,
retryable=False,
)
assert _is_client_error(synthetic)