Skip to content

Commit 9092fa5

Browse files
fix(channels): wire SCHEMA_DRIFT error_type for api/crawl4ai JSON parse failures (#71)
WIRING_GAP_LEDGER W1: the control layer's SCHEMA_DRIFT chain (error_kinds -> evaluator -> policies -> actuator) runs every 60s but only fires when a channel passes error_type explicitly — recorder's `elif error_type is not None` guard drops failures without one. api_channel.fetch() and crawl4ai_channel.fetch() both raise ChannelFetchError on JSON parse failure without error_type, so a schema drift in those channels looked like a healthy source. Set error_type=type(exc).__name__ (JSONDecodeError maps to SCHEMA_DRIFT in error_kinds) so the chain fires. Tests: JSONDecodeError -> SCHEMA_DRIFT for fetch() and collect() paths (api), malformed extracted_content -> SCHEMA_DRIFT (crawl4ai). Co-authored-by: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
1 parent 183fb8a commit 9092fa5

4 files changed

Lines changed: 74 additions & 3 deletions

File tree

backend/channels/api_channel.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,12 @@ async def fetch(self, ctx: FetchContext) -> FetchResult:
119119
try:
120120
data = response.json()
121121
except Exception as exc:
122-
raise ChannelFetchError("Failed to parse API response as JSON") from exc
122+
# WIRING_GAP_LEDGER W1: error_type must be set so the SCHEMA_DRIFT
123+
# chain (error_kinds -> control.recorder) fires instead of being
124+
# dropped by recorder's `elif error_type is not None` guard.
125+
raise ChannelFetchError(
126+
"Failed to parse API response as JSON", error_type=type(exc).__name__
127+
) from exc
123128

124129
if result_path:
125130
for key in result_path.split("."):

backend/channels/crawl4ai_channel.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,13 @@ async def fetch(self, ctx: FetchContext) -> FetchResult:
137137
try:
138138
parsed = json.loads(result.extracted_content)
139139
except (json.JSONDecodeError, TypeError) as exc:
140+
# WIRING_GAP_LEDGER W1: error_type must be set so the
141+
# SCHEMA_DRIFT chain (error_kinds -> control.recorder) fires
142+
# instead of being dropped by recorder's `elif error_type is
143+
# not None` guard.
140144
raise ChannelFetchError(
141-
"crawl4ai: could not parse extracted_content as JSON"
145+
"crawl4ai: could not parse extracted_content as JSON",
146+
error_type=type(exc).__name__,
142147
) from exc
143148
items = parsed if isinstance(parsed, list) else [parsed]
144149

tests/unit/channels/test_api_channel.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from backend.channels.api_channel import ApiChannel, _resolve_dict_secrets, _resolve_secrets
99
from backend.channels.base import ChannelFetchError, FetchContext
10+
from backend.control.error_kinds import ErrorKind, map_error_type
1011

1112

1213
@pytest.fixture(autouse=True)
@@ -647,6 +648,59 @@ async def test_fetch_http_error_raises_channel_fetch_error(channel):
647648
await channel.fetch(ctx)
648649

649650

651+
# ── WIRING_GAP_LEDGER W1: JSON parse failures must carry error_type so the
652+
# SCHEMA_DRIFT chain (error_kinds -> control.recorder) fires instead of being
653+
# dropped by recorder's `elif error_type is not None` guard. ────────────────
654+
655+
@pytest.mark.asyncio
656+
async def test_fetch_non_json_response_classified_schema_drift(channel):
657+
"""fetch() JSON parse failure carries error_type mapping to SCHEMA_DRIFT."""
658+
import json
659+
660+
mock_response = MagicMock()
661+
mock_response.status_code = 200
662+
mock_response.raise_for_status = MagicMock()
663+
mock_response.json = MagicMock(
664+
side_effect=json.JSONDecodeError("Expecting value", "doc", 0)
665+
)
666+
http = AsyncMock()
667+
http.request = AsyncMock(return_value=mock_response)
668+
ctx = FetchContext(
669+
config={"base_url": "https://api.example.com", "endpoint": "/data"},
670+
params={},
671+
http=http,
672+
)
673+
674+
with pytest.raises(ChannelFetchError) as exc_info:
675+
await channel.fetch(ctx)
676+
677+
assert exc_info.value.error_type == "JSONDecodeError"
678+
assert map_error_type(exc_info.value.error_type) is ErrorKind.SCHEMA_DRIFT
679+
680+
681+
@pytest.mark.asyncio
682+
async def test_collect_non_json_response_classified_schema_drift(channel):
683+
"""collect() propagates fetch()'s error_type through ChannelResult.fail."""
684+
import json
685+
686+
mock_response = MagicMock()
687+
mock_response.status_code = 200
688+
mock_response.raise_for_status = MagicMock()
689+
mock_response.json = MagicMock(
690+
side_effect=json.JSONDecodeError("Expecting value", "doc", 0)
691+
)
692+
mock_client_ctx, _ = _make_mock_client(mock_response)
693+
694+
with patch("httpx.AsyncClient", return_value=mock_client_ctx):
695+
result = await channel.collect(
696+
{"base_url": "https://api.example.com", "endpoint": "/data"}, {}
697+
)
698+
699+
assert result.success is False
700+
assert result.error_type == "JSONDecodeError"
701+
assert map_error_type(result.error_type) is ErrorKind.SCHEMA_DRIFT
702+
703+
650704
# ── AUDIT C13: gateway statuses classify retryable, other 4xx stay permanent ──
651705

652706
def _mock_status_error(status: int):

tests/unit/channels/test_crawl4ai_channel.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from backend.channels.base import ChannelFetchError, FetchContext
1111
from backend.channels.crawl4ai_channel import Crawl4AIChannel
12+
from backend.control.error_kinds import ErrorKind, map_error_type
1213

1314

1415
def _sessionmaker(db_engine):
@@ -127,9 +128,15 @@ async def test_fetch_malformed_extracted_content_raises(channel):
127128
config={"url": "https://example.com", "selectors": {"title": "h1"}}, params={}
128129
)
129130
with patch("crawl4ai.AsyncWebCrawler", return_value=ctx_mgr):
130-
with pytest.raises(ChannelFetchError, match="could not parse extracted_content"):
131+
with pytest.raises(ChannelFetchError) as exc_info:
131132
await channel.fetch(ctx)
132133

134+
# WIRING_GAP_LEDGER W1: parse failures must carry error_type so the
135+
# SCHEMA_DRIFT chain (error_kinds -> control.recorder) fires instead of
136+
# being dropped by recorder's `elif error_type is not None` guard.
137+
assert exc_info.value.error_type == "JSONDecodeError"
138+
assert map_error_type(exc_info.value.error_type) is ErrorKind.SCHEMA_DRIFT
139+
133140

134141
@pytest.mark.asyncio
135142
async def test_fetch_cookie_auth_passes_resolved_cookies_to_browser_config(channel):

0 commit comments

Comments
 (0)