返回 last30days-skill
test_reddit_transport_outcomes.py
根目录 / tests / test_reddit_transport_outcomes.py
1 """Reddit transport failures must not be reported as a clean no-results.
2
3 Regression coverage for issue #899: on a datacenter egress Reddit answers
4 429/403 on the keyless lanes, the adapters swallow that into an empty list, and
5 the run used to export ``"reddit": "no-results"`` with ``doctor --postmortem``
6 printing "No failures on the last run." These tests drive the real pipeline and
7 the real postmortem renderer over a mocked socket, so the whole chain --
8 capture sink -> source outcome -> postmortem bucket -- stays honest.
9 """
10
11 import urllib.error
12 from unittest import mock
13
14 from lib import doctor, http, pipeline, schema
15
16
17 def _plan(source):
18 return {
19 "intent": "general",
20 "freshness_mode": "balanced_recent",
21 "cluster_mode": "none",
22 "source_weights": {source: 1.0},
23 "subqueries": [{
24 "label": "primary",
25 "search_query": "claude code user feedback",
26 "ranking_query": "claude code user feedback",
27 "sources": [source],
28 }],
29 }
30
31
32 def _run_reddit_against(error):
33 runtime = schema.ProviderRuntime("local", "test-planner", "test-reranker")
34 with mock.patch.object(
35 pipeline.providers, "resolve_runtime", return_value=(runtime, mock.Mock())
36 ), mock.patch.object(
37 pipeline, "available_sources", return_value=["reddit"]
38 ), mock.patch("lib.http.time.sleep"), mock.patch(
39 "lib.http.urllib.request.urlopen", side_effect=error
40 ):
41 return pipeline.run(
42 topic="claude code user feedback",
43 config={"EXCLUDE_SOURCES": ""},
44 depth="quick",
45 requested_sources=["reddit"],
46 mock=False,
47 as_of_date="2026-07-10",
48 external_plan=_plan("reddit"),
49 )
50
51
52 def _postmortem_from(report):
53 return {
54 "engine_version": "test",
55 "mode": "postmortem",
56 "present": True,
57 "topic": report.topic,
58 "at": report.generated_at,
59 "outcomes": {
60 source: schema.to_dict(outcome)
61 for source, outcome in report.source_status.items()
62 },
63 }
64
65
66 def test_reddit_rate_limit_is_not_reported_as_clean_no_results():
67 report = _run_reddit_against(
68 urllib.error.HTTPError(
69 "https://www.reddit.com/search.rss", 429, "Too Many Requests", {}, None
70 )
71 )
72
73 outcome = report.source_status["reddit"]
74 assert outcome.state == schema.RATE_LIMITED
75 assert "429" in (outcome.detail or "")
76
77
78 def test_reddit_block_is_not_reported_as_clean_no_results():
79 report = _run_reddit_against(
80 urllib.error.HTTPError(
81 "https://www.reddit.com/search.rss", 403, "Blocked", {}, None
82 )
83 )
84
85 # 403 lands on auth-failed via http.classify_failure. The point of the test
86 # is that a blocked host is a failure state at all, not which noun it gets.
87 assert report.source_status["reddit"].state != schema.NO_RESULTS
88
89
90 def test_postmortem_does_not_claim_success_after_a_reddit_block():
91 report = _run_reddit_against(
92 urllib.error.HTTPError(
93 "https://www.reddit.com/search.rss", 429, "Too Many Requests", {}, None
94 )
95 )
96
97 text = doctor.render_postmortem_text(_postmortem_from(report))
98
99 assert "No failures on the last run." not in text
100 assert "Succeeded: reddit" not in text
101 assert "Failed:" in text
102 assert schema.RATE_LIMITED in text
103
104
105 def test_items_delivered_with_swallowed_lane_403_are_not_branded():
106 """Regression (datacenter egress): a source that returns items must not be
107 branded auth-failed/partial by a swallowed lane-level 403.
108
109 On hosts where Reddit blocks the shreddit partials (HTTP 403), the capture
110 sink records those failures even though the keyless lanes delivered items.
111 Before the fix, ``_retrieve_stream`` attached the captured failure as the
112 source outcome and the run reported ``auth-failed`` / ``partial after N
113 items: HTTP 403: Blocked`` for a source that actually succeeded.
114 """
115 lane_error = http.HTTPError(
116 "https://www.reddit.com/svc/shreddit/community-more-posts/top/?name=tea",
117 status_code=403,
118 body=b"Blocked",
119 )
120 subquery = schema.SubQuery(
121 label="primary",
122 search_query="matcha tea trends",
123 ranking_query="matcha tea trends",
124 sources=["reddit"],
125 )
126 with mock.patch.object(
127 pipeline,
128 "_retrieve_stream_impl",
129 return_value=([{"url": "https://www.reddit.com/r/tea/comments/abc/"}], {}),
130 ), mock.patch.object(http, "capture_failures") as cf, mock.patch.object(
131 http, "fixture_module_capture"
132 ):
133 cf.return_value.__enter__.return_value = [lane_error]
134 cf.return_value.__exit__.return_value = False
135 items, artifact = pipeline._retrieve_stream(
136 source="reddit",
137 topic="matcha tea trends",
138 subquery=subquery,
139 config={},
140 depth="quick",
141 date_range=("2026-07-08", "2026-08-08"),
142 runtime=schema.ProviderRuntime("local", "test-planner", "test-reranker"),
143 mock=False,
144 web_backend="auto",
145 )
146
147 assert items, "the run delivered items"
148 assert "_source_outcome" not in artifact, (
149 "swallowed lane failures must not brand a source that returned items"
150 )
151
152
153 def test_swallowed_lane_403_still_brands_when_no_items_returned():
154 """The no-items case keeps the transport-failure outcome (issue #899): the
155 captured failure must still surface so doctor can prescribe a fix."""
156 lane_error = http.HTTPError(
157 "https://www.reddit.com/svc/shreddit/community-more-posts/top/?name=tea",
158 status_code=403,
159 body=b"Blocked",
160 )
161 subquery = schema.SubQuery(
162 label="primary",
163 search_query="matcha tea trends",
164 ranking_query="matcha tea trends",
165 sources=["reddit"],
166 )
167 with mock.patch.object(
168 pipeline, "_retrieve_stream_impl", return_value=([], {})
169 ), mock.patch.object(http, "capture_failures") as cf, mock.patch.object(
170 http, "fixture_module_capture"
171 ):
172 cf.return_value.__enter__.return_value = [lane_error]
173 cf.return_value.__exit__.return_value = False
174 items, artifact = pipeline._retrieve_stream(
175 source="reddit",
176 topic="matcha tea trends",
177 subquery=subquery,
178 config={},
179 depth="quick",
180 date_range=("2026-07-08", "2026-08-08"),
181 runtime=schema.ProviderRuntime("local", "test-planner", "test-reranker"),
182 mock=False,
183 web_backend="auto",
184 )
185
186 assert not items
187 assert artifact.get("_source_outcome", {}).get("state") == schema.AUTH_FAILED
188
189
190 def test_items_delivered_with_swallowed_lane_failure_carry_detail():
191 """A source that delivered items keeps ``ok`` but records what was lost, so
192 ``doctor --postmortem`` can still show the swallowed sub-request failures."""
193 lane_error = http.HTTPError(
194 "https://www.reddit.com/svc/shreddit/community-more-posts/top/?name=tea",
195 status_code=429,
196 body=b"Too Many Requests",
197 )
198 subquery = schema.SubQuery(
199 label="primary",
200 search_query="matcha tea trends",
201 ranking_query="matcha tea trends",
202 sources=["reddit"],
203 )
204 with mock.patch.object(
205 pipeline,
206 "_retrieve_stream_impl",
207 return_value=([{"url": "https://www.reddit.com/r/tea/comments/abc/"}], {}),
208 ), mock.patch.object(http, "capture_failures") as cf, mock.patch.object(
209 http, "fixture_module_capture"
210 ):
211 cf.return_value.__enter__.return_value = [lane_error, lane_error]
212 cf.return_value.__exit__.return_value = False
213 items, artifact = pipeline._retrieve_stream(
214 source="reddit",
215 topic="matcha tea trends",
216 subquery=subquery,
217 config={},
218 depth="quick",
219 date_range=("2026-07-08", "2026-08-08"),
220 runtime=schema.ProviderRuntime("local", "test-planner", "test-reranker"),
221 mock=False,
222 web_backend="auto",
223 )
224
225 assert items
226 assert "_source_outcome" not in artifact
227 detail = artifact.get("_source_outcome_detail") or ""
228 assert "2 sub-requests" in detail
229 assert "429" in detail
230
231
232 def test_postmortem_shows_lane_detail_on_succeeded_source():
233 outcome = schema.SourceOutcome(
234 source="reddit",
235 state="ok",
236 items_returned=36,
237 attempted=True,
238 detail="3 sub-requests rate-limited (HTTP 429)",
239 )
240 pm = {
241 "engine_version": "test",
242 "mode": "postmortem",
243 "present": True,
244 "topic": "kanye west",
245 "at": outcome.at,
246 "outcomes": {"reddit": schema.to_dict(outcome)},
247 }
248
249 text = doctor.render_postmortem_text(pm)
250
251 assert "Failed:" not in text
252 assert "Partial:" not in text
253 assert "Succeeded: reddit (36 items; 3 sub-requests rate-limited (HTTP 429))" in text
254
255
256 def test_swallowed_429s_flag_the_source_as_rate_limited_for_thin_retry():
257 lane_error = http.HTTPError(
258 "https://www.reddit.com/search.rss", status_code=429, body=b"Too Many Requests"
259 )
260 subquery = schema.SubQuery(
261 label="primary", search_query="matcha", ranking_query="matcha", sources=["reddit"],
262 )
263 with mock.patch.object(
264 pipeline, "_retrieve_stream_impl",
265 return_value=([{"url": "https://www.reddit.com/r/tea/comments/abc/"}], {}),
266 ), mock.patch.object(http, "capture_failures") as cf, mock.patch.object(http, "fixture_module_capture"):
267 cf.return_value.__enter__.return_value = [lane_error]
268 cf.return_value.__exit__.return_value = False
269 _items, artifact = pipeline._retrieve_stream(
270 source="reddit", topic="matcha", subquery=subquery, config={}, depth="quick",
271 date_range=("2026-07-08", "2026-08-08"),
272 runtime=schema.ProviderRuntime("local", "test-planner", "test-reranker"),
273 mock=False, web_backend="auto",
274 )
275 assert artifact["_source_outcome_detail_state"] == schema.RATE_LIMITED
276
276 lines PYTHON