Files
hermes-agent/tests/agent/test_error_classifier_upstream_blocked.py
teknium1 6f6ed01355 fix: WAF 403s stop reading as key rejections; anthropic_messages routes send custom_providers extra_headers
Two gaps for custom providers behind a WAF/CDN:

- `build_anthropic_client` never consulted `custom_providers[].extra_headers`,
  so a relay in `anthropic_messages` mode that rejects the SDK User-Agent kept
  403ing even with `extra_headers: {User-Agent: ...}` configured, while the
  OpenAI-wire clients already applied it. The lookup now lives in
  `_new_sdk_client`, the one constructor every builder path goes through
  (init, /model switch, rebuild, auxiliary), keyed by the caller's raw route
  because entries are keyed by the `/v1` form the normalizer strips.
  Salvaged direction of #46002 (@wait4xx). Fixes #24293, #9721.

- `_status_403` classified every non-billing 403 as `auth`, so a WAF's plain
  "Your request was blocked." or a Cloudflare browser challenge printed "Your
  API key was rejected" and could rotate a healthy credential. A 403 carrying
  established block/challenge markers is now `upstream_blocked`: no rotation,
  no retry, fallback allowed, WAF/User-Agent guidance on every surface (CLI
  loop, chat copy, cli chat error copy, TUI gateway + Ink TUI copy). Generic
  403 and all 401 keep the auth verdict. Salvaged direction of #70567
  (@ooiuuii) and #53114 (@AgenticSpark). Fixes #53099, #70566.
2026-09-19 09:57:21 -07:00

35 lines
1.5 KiB
Python

"""A 403 written by a WAF/CDN in front of the provider is not an API-key rejection (#53099, #70566).
A relay that blocks the SDK User-Agent answers ``403 Your request was blocked.``; Cloudflare's
browser challenge answers 403 HTML. Both used to classify as ``auth`` and print key guidance.
"""
import pytest
from agent.error_classifier import FailoverReason, classify_api_error
class _APIError(Exception):
def __init__(self, message, status_code):
super().__init__(message)
self.status_code = status_code
@pytest.mark.parametrize("body", [
"Error code: 403 - Your request was blocked.",
"<!doctype html><html><body>Enable JavaScript and cookies to continue</body></html>",
"<!doctype html><html><script src='/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page'></script></html>",
])
def test_403_waf_block_is_upstream_blocked_not_auth(body):
result = classify_api_error(_APIError(body, 403), provider="openai-api")
assert result.reason == FailoverReason.upstream_blocked
assert result.retryable is False and result.should_fallback is True
assert result.should_rotate_credential is False and result.is_auth is False
@pytest.mark.parametrize("body, status, reason", [
("<html><title>Forbidden</title><body>Access denied</body></html>", 403, FailoverReason.auth),
("<html>Enable JavaScript and cookies to continue</html>", 401, FailoverReason.auth),
])
def test_generic_403_and_all_401_keep_auth(body, status, reason):
assert classify_api_error(_APIError(body, status), provider="openai-api").reason == reason