Skip to content

Commit 183fb8a

Browse files
fix(rss): detect empty or HTML responses feedparser treats as non-bozo (#36) (#69)
feedparser reports bozo=False with entries=[] for an empty HTTP 200 body or an HTML error page, making them look like a healthy empty feed (no structured error reaches control, SCHEMA_DRIFT never fires). Fail explicitly with a structured ParseError (maps to SCHEMA_DRIFT in error_kinds.py) when no feed version was detected, on both collect() and fetch() paths. Conservative: a real zero-entry feed still carries a detected version ('rss20'/'atom10') and passes. Co-authored-by: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
1 parent d66ce32 commit 183fb8a

3 files changed

Lines changed: 168 additions & 0 deletions

File tree

backend/channels/rss_channel.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,37 @@ def _bozo_error_type(parsed: Any) -> str:
4343
return "ParseError"
4444

4545

46+
def _parsed_version(parsed: Any) -> str:
47+
"""Detected feed format (``'rss20'``, ``'atom10'``, ...), or ``''`` when
48+
feedparser recognized no feed format in the body. Works for both
49+
feedparser's dict-like result and bare namespaces used in tests."""
50+
getter = getattr(parsed, "get", None)
51+
if callable(getter):
52+
return str(getter("version") or "")
53+
return str(getattr(parsed, "version", "") or "")
54+
55+
56+
def _non_feed_response(parsed: Any) -> bool:
57+
"""True when feedparser accepted the body but recognized no feed format.
58+
59+
Issue #36: an HTTP 200 with an empty body or an HTML error page parses as
60+
``bozo=False`` with ``entries=[]`` — visually identical to a legitimate
61+
empty feed, so no structured error reaches control. The distinguishing
62+
signal is the detected feed version: a real (even empty) RSS/Atom feed
63+
parses with ``version`` set (``'rss20'``/``'atom10'``/...), while
64+
empty/HTML/non-feed bodies leave it falsy (``''``/``None``). Conservative
65+
by design: this only fires when zero entries were parsed, so a partial
66+
parse that yielded items still succeeds. ``ParseError`` maps to
67+
SCHEMA_DRIFT in backend/control/error_kinds.py, so these responses now
68+
reach the control recorder instead of looking like a healthy empty feed.
69+
"""
70+
return (
71+
not getattr(parsed, "bozo", False)
72+
and not getattr(parsed, "entries", None)
73+
and not _parsed_version(parsed)
74+
)
75+
76+
4677
@register_channel
4778
class RSSChannel(AbstractChannel):
4879
"""Collect entries from RSS/Atom feeds."""
@@ -127,6 +158,16 @@ async def collect(
127158
f"Failed to parse feed: {getattr(parsed, 'bozo_exception', 'unknown error')}",
128159
error_type=_bozo_error_type(parsed),
129160
)
161+
# Issue #36: feedparser treats an empty body or an HTML error page as a
162+
# successful non-bozo parse with entries=[] — indistinguishable from a
163+
# legitimate empty feed by bozo alone. Fail explicitly (ParseError maps
164+
# to SCHEMA_DRIFT) when no feed format was recognized.
165+
if _non_feed_response(parsed):
166+
return ChannelResult.fail(
167+
"RSS feed returned no recognized feed content "
168+
"(empty body or HTML/non-feed response)",
169+
error_type="ParseError",
170+
)
130171

131172
entries = parsed.entries[:max_entries]
132173
items = [self._entry_to_dict(entry) for entry in entries]
@@ -223,6 +264,15 @@ async def fetch(self, ctx: FetchContext) -> FetchResult:
223264
f"Failed to parse feed: {getattr(parsed, 'bozo_exception', 'unknown error')}",
224265
error_type=_bozo_error_type(parsed),
225266
)
267+
# Issue #36: see collect()'s twin comment — an empty/HTML response
268+
# parses as bozo=False with entries=[] and must not look like a
269+
# healthy empty feed.
270+
if _non_feed_response(parsed):
271+
raise ChannelFetchError(
272+
"RSS feed returned no recognized feed content "
273+
"(empty body or HTML/non-feed response)",
274+
error_type="ParseError",
275+
)
226276
items = [self._entry_to_dict(entry) for entry in parsed.entries[:max_entries]]
227277

228278
next_cursor = dict(cursor)

tests/unit/channels/test_rss_channel_schema_drift.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ async def get(self, *_args, **_kwargs):
3030
return self.response
3131

3232

33+
class _BodyClient:
34+
"""Collect-side client serving a fixed body (Issue #36 cases)."""
35+
36+
def __init__(self, text: str):
37+
self.response = MagicMock(text=text)
38+
self.response.raise_for_status = MagicMock()
39+
40+
async def __aenter__(self):
41+
return self
42+
43+
async def __aexit__(self, *_args):
44+
return False
45+
46+
async def get(self, *_args, **_kwargs):
47+
return self.response
48+
49+
3350
async def _collect_with_parsed(channel, parsed):
3451
with (
3552
patch("httpx.AsyncClient", return_value=_HttpClient()),
@@ -86,3 +103,57 @@ async def test_collect_bozo_feed_with_entries_succeeds(channel):
86103

87104
assert result.success is True
88105
assert len(result.items) == 1
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_collect_html_error_page_fails_with_parse_error(channel):
110+
"""Issue #36: an HTML error page parses as bozo=False with entries=[] —
111+
previously returned OK as a fake empty feed; now fails with a structured
112+
ParseError that maps to SCHEMA_DRIFT."""
113+
html = (
114+
"<!DOCTYPE html><html><head><title>Error 502</title></head>"
115+
"<body><h1>Bad Gateway</h1><p>nginx</p></body></html>"
116+
)
117+
with patch("httpx.AsyncClient", return_value=_BodyClient(html)):
118+
result = await channel.collect({"feed_url": "https://example.com/rss"}, {})
119+
120+
assert result.success is False
121+
assert result.error_type == "ParseError"
122+
assert map_error_type(result.error_type) is ErrorKind.SCHEMA_DRIFT
123+
124+
125+
@pytest.mark.asyncio
126+
async def test_collect_empty_body_fails_with_parse_error(channel):
127+
"""Issue #36: an empty HTTP 200 body parses as bozo=False with entries=[]
128+
— must fail as ParseError (SCHEMA_DRIFT), not look like a healthy feed."""
129+
with patch("httpx.AsyncClient", return_value=_BodyClient("")):
130+
result = await channel.collect({"feed_url": "https://example.com/rss"}, {})
131+
132+
assert result.success is False
133+
assert result.error_type == "ParseError"
134+
assert map_error_type(result.error_type) is ErrorKind.SCHEMA_DRIFT
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_collect_plain_xml_not_a_feed_fails_with_parse_error(channel):
139+
"""Issue #36: well-formed XML that is not a feed (no recognized version)
140+
must not be reported as a successful empty feed."""
141+
with patch("httpx.AsyncClient", return_value=_BodyClient("<foo><bar>1</bar></foo>")):
142+
result = await channel.collect({"feed_url": "https://example.com/rss"}, {})
143+
144+
assert result.success is False
145+
assert result.error_type == "ParseError"
146+
assert map_error_type(result.error_type) is ErrorKind.SCHEMA_DRIFT
147+
148+
149+
@pytest.mark.asyncio
150+
async def test_collect_valid_empty_feed_succeeds(channel):
151+
"""A legitimate zero-entry feed (feedparser detects its version) must NOT
152+
be flagged as a non-feed response."""
153+
empty_rss = '<rss version="2.0"><channel><title>Empty</title></channel></rss>'
154+
with patch("httpx.AsyncClient", return_value=_BodyClient(empty_rss)):
155+
result = await channel.collect({"feed_url": "https://example.com/rss"}, {})
156+
157+
assert result.success is True
158+
assert result.items == []
159+
assert result.metadata.get("total_entries") == 0

tests/unit/channels/test_rss_fetch.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,53 @@ async def test_fetch_bozo_feed_raises_error_type_mapped_to_schema_drift():
147147
assert map_error_type(exc_info.value.error_type) is ErrorKind.SCHEMA_DRIFT
148148

149149

150+
@pytest.mark.asyncio
151+
async def test_fetch_html_response_raises_error_type_mapped_to_schema_drift():
152+
"""Issue #36: an HTML error page parses as bozo=False with entries=[] on
153+
the fetch() path too — must raise ChannelFetchError(ParseError), mapped
154+
to SCHEMA_DRIFT, instead of returning a fake empty feed."""
155+
from backend.control.error_kinds import ErrorKind, map_error_type
156+
157+
http = _Http(_Resp(200, text="<html><body>Error 502</body></html>", headers={}))
158+
ctx = FetchContext(config={"feed_url": "https://x/feed"}, params={}, cursor=None, http=http)
159+
160+
with pytest.raises(ChannelFetchError) as exc_info:
161+
await RSSChannel().fetch(ctx)
162+
163+
assert exc_info.value.error_type == "ParseError"
164+
assert map_error_type(exc_info.value.error_type) is ErrorKind.SCHEMA_DRIFT
165+
166+
167+
@pytest.mark.asyncio
168+
async def test_fetch_empty_body_raises_error_type_mapped_to_schema_drift():
169+
"""Issue #36: an empty HTTP 200 body on the fetch() path must raise
170+
ParseError (SCHEMA_DRIFT), not return a fake empty feed."""
171+
from backend.control.error_kinds import ErrorKind, map_error_type
172+
173+
http = _Http(_Resp(200, text="", headers={}))
174+
ctx = FetchContext(config={"feed_url": "https://x/feed"}, params={}, cursor=None, http=http)
175+
176+
with pytest.raises(ChannelFetchError) as exc_info:
177+
await RSSChannel().fetch(ctx)
178+
179+
assert exc_info.value.error_type == "ParseError"
180+
assert map_error_type(exc_info.value.error_type) is ErrorKind.SCHEMA_DRIFT
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_fetch_valid_empty_feed_succeeds():
185+
"""A legitimate zero-entry feed (version detected) must NOT be flagged as
186+
a non-feed response on the fetch() path."""
187+
empty_rss = '<rss version="2.0"><channel><title>Empty</title></channel></rss>'
188+
http = _Http(_Resp(200, text=empty_rss, headers={}))
189+
ctx = FetchContext(config={"feed_url": "https://x/feed"}, params={}, cursor=None, http=http)
190+
191+
result = await RSSChannel().fetch(ctx)
192+
193+
assert result.items == []
194+
assert result.has_more is False
195+
196+
150197
@pytest.mark.asyncio
151198
async def test_run_channel_drives_rss_and_persists_cursor():
152199
from backend.pipeline.channel_runner import run_channel

0 commit comments

Comments
 (0)