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.
35 lines
1.5 KiB
Python
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
|