Files
connor c03df8c1b5 Treat a 403 as blocked rather than an error
Reddit returns 403 or 429 depending on the moment -- measured across four
subreddits in one pass: two 403, two 429, while three of the four had ingested
44-55 items earlier, so the source works intermittently. Only the 429 was
handled softly, so the 403 raised and logged an error every fifteen minutes for
a source that was fine.

Neither status is actionable per cycle. Both now back off quietly and record the
status on the source row, where it is visible via `news sources`.
2026-09-09 10:04:38 -04:00

90 lines
2.9 KiB
Python

"""RSS/Atom parsing, including the RDF format the old system could not read."""
import httpx
import pytest
from news.sources import rss
RDF = """<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/">
<channel rdf:about="http://x"><title>T</title><link>http://x</link>
<description>d</description></channel>
<item rdf:about="http://x/1">
<title>German economy frets about election result</title>
<link>http://x/1?utm_source=rss</link>
<description>Some &lt;b&gt;markup&lt;/b&gt; here</description>
</item>
</rdf:RDF>"""
ATOM = """<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>F</title>
<entry>
<title>Release v1.2.3</title>
<link rel="alternate" href="http://x/rel"/>
<link rel="self" href="http://x/self"/>
<updated>2026-09-08T12:00:00Z</updated>
</entry>
</feed>"""
def _source():
return {"id": "s1", "url": "http://feed", "kind": "rss"}
async def _fetch(body: str, status: int = 200, headers: dict | None = None):
def handler(request):
return httpx.Response(status, content=body.encode(), headers=headers or {})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
return await rss.fetch(client, _source())
@pytest.mark.asyncio
async def test_parses_rss_1_rdf():
"""DW ships RDF; the old hand-rolled parser yielded zero items silently."""
items, _ = await _fetch(RDF)
assert len(items) == 1
assert items[0]["title"].startswith("German economy")
# tracking parameter stripped, markup removed from the summary
assert items[0]["canonical_url"] == "http://x/1"
assert "<b>" not in items[0]["summary"] and "markup" in items[0]["summary"]
@pytest.mark.asyncio
async def test_atom_prefers_alternate_link_over_self():
items, _ = await _fetch(ATOM)
assert len(items) == 1
assert items[0]["canonical_url"] == "http://x/rel"
@pytest.mark.asyncio
async def test_304_returns_no_items_and_is_not_an_error():
items, state = await _fetch("", status=304)
assert items == [] and state["status"] == "304"
@pytest.mark.asyncio
async def test_429_is_reported_as_rate_limited_not_raised():
"""Reddit 429s readily; that must back off, not raise every cycle."""
items, state = await _fetch("", status=429, headers={"retry-after": "120"})
assert items == []
assert state["rate_limited"] is True
assert state["retry_after"] == "120"
@pytest.mark.asyncio
async def test_403_is_also_treated_as_blocked_not_an_error():
"""Reddit returns 403 or 429 depending on the moment; neither is actionable
per cycle, and raising on one logged an error every fifteen minutes."""
items, state = await _fetch("", status=403)
assert items == [] and state["rate_limited"] is True
@pytest.mark.asyncio
async def test_server_error_still_raises():
with pytest.raises(httpx.HTTPStatusError):
await _fetch("", status=503)