Skip to content

Commit bc4111f

Browse files
bokelleyclaude
andauthored
fix(adagents): raise AdagentsAccessBlockedError on 403 + cf-mitigated: challenge (#837)
* fix(adagents): raise AdagentsAccessBlockedError on 403 + cf-mitigated: challenge When _fetch_adagents_url receives HTTP 403 with cf-mitigated: challenge, raise AdagentsAccessBlockedError (AdagentsValidationError subclass) so callers can distinguish Cloudflare bot-management blocks from other errors. Existing except AdagentsValidationError handlers catch it automatically. Closes #801 * fix(adagents): address pre-PR review findings - Add parametrized test for capitalized cf-mitigated: Challenge (Cloudflare's actual casing) - Add .strip() to header comparison for OWS robustness - Document AdagentsAccessBlockedError in fetch_adagents Raises section - Add explanatory comments at sites that intentionally swallow the subclass * test(adagents): avoid URL substring assertion --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c547dfe commit bc4111f

5 files changed

Lines changed: 98 additions & 2 deletions

File tree

src/adcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
)
5050
from adcp.client import ADCPClient, ADCPMultiAgentClient, Checkpoint
5151
from adcp.exceptions import ( # noqa: F401
52+
AdagentsAccessBlockedError,
5253
AdagentsNotFoundError,
5354
AdagentsTimeoutError,
5455
AdagentsValidationError,
@@ -947,6 +948,7 @@ def get_adcp_version() -> str:
947948
"ADCPSigningRequiredError",
948949
"ADCPWebhookError",
949950
"ADCPWebhookSignatureError",
951+
"AdagentsAccessBlockedError",
950952
"AdagentsValidationError",
951953
"AdagentsNotFoundError",
952954
"AdagentsTimeoutError",

src/adcp/adagents.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
import httpx
2323
from pydantic import Field
2424

25-
from adcp.exceptions import AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError
25+
from adcp.exceptions import (
26+
AdagentsAccessBlockedError,
27+
AdagentsNotFoundError,
28+
AdagentsTimeoutError,
29+
AdagentsValidationError,
30+
)
2631
from adcp.types.base import AdCPBaseModel
2732
from adcp.validation import ValidationError, validate_adagents
2833

@@ -779,6 +784,9 @@ async def fetch_adagents(
779784
Raises:
780785
AdagentsNotFoundError: If adagents.json was not found via any
781786
discovery path.
787+
AdagentsAccessBlockedError: If the publisher's CDN returns HTTP
788+
403 with ``cf-mitigated: challenge`` (Cloudflare bot-management
789+
block). Subclass of ``AdagentsValidationError``.
782790
AdagentsValidationError: If JSON is invalid, malformed, or
783791
redirects exceed maximum depth or form a loop.
784792
AdagentsTimeoutError: If request times out.
@@ -885,6 +893,10 @@ async def _try_managerdomain_fallback(
885893
)
886894
return data
887895
except (AdagentsNotFoundError, AdagentsValidationError, AdagentsTimeoutError):
896+
# AdagentsAccessBlockedError (a AdagentsValidationError subclass) is
897+
# intentionally swallowed here: a bot-blocked manager domain is treated
898+
# as "not available", and fetch_adagents re-raises the original
899+
# AdagentsNotFoundError for the primary domain.
888900
return None
889901

890902

@@ -1077,6 +1089,12 @@ async def _fetch_adagents_url(
10771089
parsed = urlparse(url)
10781090
raise AdagentsNotFoundError(parsed.netloc)
10791091

1092+
if status_code == 403 and (
1093+
response_headers.get("cf-mitigated", "").strip().lower() == "challenge"
1094+
):
1095+
parsed = urlparse(url)
1096+
raise AdagentsAccessBlockedError(parsed.netloc)
1097+
10801098
if status_code != 200:
10811099
raise AdagentsValidationError(f"Failed to fetch adagents.json: HTTP {status_code}")
10821100

@@ -1925,7 +1943,10 @@ async def fetch_authorization_for_domain(
19251943
return (domain, AuthorizationContext(properties))
19261944

19271945
except (AdagentsNotFoundError, AdagentsValidationError, AdagentsTimeoutError):
1928-
# Silently skip domains with missing or invalid adagents.json
1946+
# Silently skip domains with missing or invalid adagents.json.
1947+
# AdagentsAccessBlockedError (AdagentsValidationError subclass) is
1948+
# intentionally swallowed: a bot-blocked domain is treated as
1949+
# authorization-unavailable, same as a missing file.
19291950
return (domain, None)
19301951

19311952
# Fetch all domains in parallel

src/adcp/exceptions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,38 @@ def __init__(self, publisher_domain: str, timeout: float):
285285
super().__init__(message, None, None, suggestion)
286286

287287

288+
class AdagentsAccessBlockedError(AdagentsValidationError):
289+
"""adagents.json fetch blocked by publisher-side bot management (403, cf-mitigated: challenge).
290+
291+
Only surfaces in direct-fetch workflows (``fetch_adagents``). SDK callers that
292+
use ``fetch_agent_authorizations`` avoid this entirely — the AAO directory crawler
293+
handles publisher fetches and serves cached results without exposing the SDK to
294+
publisher-side bot management.
295+
296+
If you need to catch this specifically without catching all
297+
``AdagentsValidationError``s, use ``except AdagentsAccessBlockedError``.
298+
"""
299+
300+
def __init__(self, publisher_domain: str):
301+
"""Initialize bot-management blocked error."""
302+
self.publisher_domain = publisher_domain
303+
message = (
304+
f"adagents.json blocked by bot management for {publisher_domain} "
305+
f"(HTTP 403, cf-mitigated: challenge)"
306+
)
307+
suggestion = (
308+
"The publisher's origin blocked this request with a Cloudflare bot management\n"
309+
" challenge. This only affects direct adagents.json fetches (fetch_adagents).\n"
310+
"\n"
311+
" To unblock local debugging:\n"
312+
" - Retry with a browser-like User-Agent via the user_agent= parameter, e.g.\n"
313+
' user_agent="Mozilla/5.0"\n'
314+
" - Or call fetch_agent_authorizations() to query the AAO directory instead,\n"
315+
" which bypasses publisher-side bot management entirely."
316+
)
317+
super().__init__(message, None, None, suggestion)
318+
319+
288320
class ADCPTaskError(ADCPError):
289321
"""A task returned an ADCP error response.
290322

tests/fixtures/public_api_snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"ActivateSignalResponse1",
3131
"ActivateSignalSuccessResponse",
3232
"AdAgentsValidationResult",
33+
"AdagentsAccessBlockedError",
3334
"AdagentsCacheEntry",
3435
"AdagentsEntryError",
3536
"AdagentsFetchResult",

tests/test_adagents.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
verify_agent_authorization,
2424
)
2525
from adcp.exceptions import (
26+
AdagentsAccessBlockedError,
2627
AdagentsValidationError,
2728
)
2829

@@ -674,6 +675,45 @@ def _stream(method, url, **kwargs):
674675
# Should stop after reasonable number of redirects (not go forever)
675676
assert call_count[0] <= 10
676677

678+
@pytest.mark.asyncio
679+
@pytest.mark.parametrize("cf_value", ["challenge", "Challenge"])
680+
async def test_fetch_403_cf_mitigated_raises_access_blocked(self, cf_value):
681+
"""403 + cf-mitigated: challenge raises AdagentsAccessBlockedError (case-insensitive)."""
682+
from adcp.adagents import fetch_adagents
683+
684+
mock_response = MagicMock()
685+
mock_response.status_code = 403
686+
mock_response.headers = httpx.Headers({"cf-mitigated": cf_value})
687+
mock_response.json.return_value = None
688+
689+
mock_client = create_mock_httpx_client(mock_response)
690+
691+
with pytest.raises(AdagentsAccessBlockedError, match="cf-mitigated: challenge"):
692+
await fetch_adagents("cafemedia.com", client=mock_client)
693+
694+
@pytest.mark.asyncio
695+
async def test_fetch_403_no_cf_header_raises_generic_validation_error(self):
696+
"""Plain 403 without cf-mitigated header raises generic AdagentsValidationError."""
697+
from adcp.adagents import fetch_adagents
698+
699+
mock_response = MagicMock()
700+
mock_response.status_code = 403
701+
mock_response.headers = httpx.Headers({})
702+
mock_response.json.return_value = None
703+
704+
mock_client = create_mock_httpx_client(mock_response)
705+
706+
with pytest.raises(AdagentsValidationError, match="HTTP 403") as exc_info:
707+
await fetch_adagents("example.com", client=mock_client)
708+
assert not isinstance(exc_info.value, AdagentsAccessBlockedError)
709+
710+
def test_access_blocked_is_subclass_of_validation_error(self):
711+
"""AdagentsAccessBlockedError is catchable as AdagentsValidationError."""
712+
err = AdagentsAccessBlockedError("cafemedia.com")
713+
assert isinstance(err, AdagentsValidationError)
714+
assert "cf-mitigated: challenge" in str(err)
715+
assert err.publisher_domain == "cafemedia.com"
716+
677717

678718
class TestSSRFProtection:
679719
"""Test SSRF protections on authoritative_location redirects."""

0 commit comments

Comments
 (0)