返回 last30days-skill
test_source_outcomes.py
根目录 / tests / test_source_outcomes.py
1 import socket
2 import urllib.error
3 from unittest.mock import MagicMock, patch
4
5 import pytest
6
7 from lib import (
8 bird_x,
9 health,
10 http,
11 jobs,
12 pipeline,
13 reddit,
14 reddit_listing,
15 render,
16 schema,
17 youtube_yt,
18 )
19
20
21 def _report(*, source_status=None, items_by_source=None, errors_by_source=None):
22 return schema.Report(
23 topic="test topic",
24 range_from="2026-06-10",
25 range_to="2026-07-10",
26 generated_at="2026-07-10T18:22:03Z",
27 provider_runtime=schema.ProviderRuntime(
28 reasoning_provider="gemini",
29 planner_model="test-planner",
30 rerank_model="test-reranker",
31 ),
32 query_plan=schema.QueryPlan(
33 intent="general",
34 freshness_mode="balanced_recent",
35 cluster_mode="story",
36 raw_topic="test topic",
37 subqueries=[
38 schema.SubQuery(
39 label="primary",
40 search_query="test topic",
41 ranking_query="test topic",
42 sources=["x"],
43 )
44 ],
45 source_weights={"x": 1.0},
46 ),
47 clusters=[],
48 ranked_candidates=[],
49 items_by_source=items_by_source or {},
50 errors_by_source=errors_by_source or {},
51 source_status=source_status or {},
52 )
53
54
55 @pytest.mark.parametrize(
56 ("error", "expected"),
57 [
58 (http.HTTPError("HTTP 429", status_code=429), schema.RATE_LIMITED),
59 (http.HTTPError("HTTP 401", status_code=401), schema.AUTH_FAILED),
60 (http.HTTPError("HTTP 402", status_code=402), schema.PAYMENT_REQUIRED),
61 (http.HTTPError("HTTP 403", status_code=403), schema.AUTH_FAILED),
62 (http.HTTPError("Invalid JSON response"), schema.SCHEMA_DRIFT),
63 (http.HTTPError("Connection error: reset"), schema.UNREACHABLE),
64 (http.HTTPError("Request timed out"), health.TIMEOUT),
65 ],
66 )
67 def test_http_error_exposes_run_outcome_state(error, expected):
68 assert error.outcome_state == expected
69
70
71 @patch("lib.http.time.sleep")
72 @patch("lib.http.urllib.request.urlopen")
73 def test_http_wrapper_classifies_dns_failure(mock_urlopen, _mock_sleep):
74 mock_urlopen.side_effect = urllib.error.URLError(
75 socket.gaierror(-2, "Name or service not known")
76 )
77
78 with pytest.raises(http.HTTPError) as caught:
79 http.get("https://unreachable.example", retries=1)
80
81 assert caught.value.outcome_state == schema.UNREACHABLE
82
83
84 def test_source_specific_text_failures_are_mapped():
85 assert bird_x.classify_run_failure("likely Twitter anti-bot interstitial") == schema.SCHEMA_DRIFT
86 assert reddit.classify_run_failure("blocked by Reddit interstitial") == schema.RATE_LIMITED
87 assert youtube_yt.classify_run_failure("Sign in to confirm you're not a bot") == schema.RATE_LIMITED
88 assert youtube_yt.classify_run_failure("Search timed out after 1s") == health.TIMEOUT
89
90
91 def test_bundle_distinguishes_clean_no_results_from_failure():
92 clean = schema.RetrievalBundle()
93 clean.mark_attempted("x")
94
95 failed = schema.RetrievalBundle()
96 failed.mark_attempted("x")
97 failed.record_failure("x", schema.RATE_LIMITED, "HTTP 429")
98
99 assert clean.source_status["x"].state == schema.NO_RESULTS
100 assert failed.source_status["x"].state == schema.RATE_LIMITED
101 assert failed.source_status["x"].fix_hint == "doctor"
102
103
104 @patch("lib.http.urllib.request.urlopen")
105 def test_stream_adapter_recovers_http_failure_laundered_as_empty(mock_urlopen):
106 mock_urlopen.side_effect = urllib.error.HTTPError(
107 "https://api.example.com",
108 401,
109 "Unauthorized",
110 {},
111 None,
112 )
113
114 def source_that_launders_failure(*_args, **_kwargs):
115 try:
116 http.get("https://api.example.com", retries=1)
117 except http.HTTPError:
118 return [], {}
119 raise AssertionError("request should have failed")
120
121 with patch("lib.pipeline._retrieve_stream_impl", side_effect=source_that_launders_failure):
122 items, artifact = pipeline._retrieve_stream()
123
124 assert items == []
125 assert artifact["_source_outcome"]["state"] == schema.AUTH_FAILED
126
127
128 @patch("lib.http.time.sleep")
129 @patch("lib.http.urllib.request.urlopen")
130 def test_reddit_nested_worker_propagates_failure_capture(mock_urlopen, _mock_sleep):
131 mock_urlopen.side_effect = urllib.error.HTTPError(
132 "https://api.scrapecreators.com/v1/reddit/search",
133 429,
134 "Too Many Requests",
135 {},
136 None,
137 )
138
139 with http.capture_failures() as failures:
140 result = reddit.search_reddit(
141 "test topic",
142 "2026-06-10",
143 "2026-07-10",
144 depth="quick",
145 token="dummy-token",
146 )
147
148 assert result["items"] == []
149 assert failures[-1].outcome_state == schema.RATE_LIMITED
150
151
152 def _reddit_429(url="https://www.reddit.com/svc/shreddit/search/"):
153 return urllib.error.HTTPError(url, 429, "Too Many Requests", {}, None)
154
155
156 @patch("lib.http.time.sleep")
157 @patch("lib.http.urllib.request.urlopen")
158 def test_reddit_listing_fanout_propagates_failure_capture(mock_urlopen, _mock_sleep):
159 mock_urlopen.side_effect = _reddit_429(
160 "https://www.reddit.com/svc/shreddit/community-more-posts/hot/"
161 )
162
163 with http.capture_failures() as failures:
164 posts = reddit_listing.fetch_listings(["example"], depth="quick", query="test topic")
165
166 assert posts == []
167 assert failures[-1].outcome_state == schema.RATE_LIMITED
168
169
170 @patch("lib.http.time.sleep")
171 @patch("lib.http.urllib.request.urlopen")
172 def test_reddit_discovery_listing_fanout_propagates_failure_capture(mock_urlopen, _mock_sleep):
173 mock_urlopen.side_effect = _reddit_429(
174 "https://www.reddit.com/svc/shreddit/community-more-posts/rising/"
175 )
176
177 with http.capture_failures() as failures:
178 result = reddit_listing.fetch_discovery_listings(["example"], query="test topic")
179
180 assert result["items"] == []
181 # The discovery path reads this list, not the sink — a blocked feed must not
182 # look like an empty one there either.
183 assert result["errors"]
184 assert any("429" in error for error in result["errors"])
185 assert failures[-1].outcome_state == schema.RATE_LIMITED
186
187
188 @patch("lib.http.urllib.request.urlopen")
189 def test_tee_failures_does_not_hide_from_parent_sink(mock_urlopen):
190 # tee_failures must never become capture_failures: the latter replaces the
191 # sink, which would silently re-break the Reddit lanes above.
192 mock_urlopen.side_effect = urllib.error.HTTPError(
193 "https://api.example.com/missing", 404, "Not Found", {}, None
194 )
195
196 with http.capture_failures() as parent:
197 with http.tee_failures() as local:
198 with pytest.raises(http.HTTPError):
199 http.get("https://api.example.com/missing", retries=1)
200
201 assert len(local) == 1
202 assert local == parent
203
204
205 @patch("lib.http.urllib.request.urlopen")
206 def test_jobs_expected_probe_misses_do_not_degrade_final_result(mock_urlopen):
207 miss = urllib.error.HTTPError(
208 "https://boards-api.greenhouse.io/v1/boards/example/jobs",
209 404,
210 "Not Found",
211 {},
212 None,
213 )
214 success = MagicMock()
215 success.status = 200
216 success.read.return_value = (
217 b'{"jobs":[{"id":"1","title":"Engineer",'
218 b'"jobUrl":"https://jobs.ashbyhq.com/example/1"}]}'
219 )
220 success.__enter__.return_value = success
221 success.__exit__.return_value = False
222 mock_urlopen.side_effect = [miss, success]
223
224 with patch("lib.jobs._candidate_slugs", return_value=["example"]):
225 with http.capture_failures() as failures:
226 provider, slug, _ = jobs._probe_ats("Example")
227
228 assert provider == jobs.ATS_PROVIDER_ASHBY
229 assert slug == "example"
230 assert failures == []
231
232
233 @pytest.mark.parametrize(
234 ("source", "artifact", "expected"),
235 [
236 ("perplexity", {"error": "timeout"}, health.TIMEOUT),
237 (
238 "grounding",
239 {"reason": "keyless-search-unavailable"},
240 schema.UNREACHABLE,
241 ),
242 ],
243 )
244 def test_stream_adapter_converts_legacy_error_artifacts(source, artifact, expected):
245 with patch("lib.pipeline._retrieve_stream_impl", return_value=([], artifact)):
246 _, converted = pipeline._retrieve_stream(source=source)
247
248 assert converted["_source_outcome"]["state"] == expected
249
250
251 @pytest.mark.parametrize(
252 ("source", "detail", "expected"),
253 [
254 ("truthsocial", "Truth Social token expired", schema.AUTH_FAILED),
255 (
256 "bluesky",
257 "Cloudflare blocked the request (403 Forbidden). This is a network-level block, not an auth issue.",
258 schema.UNREACHABLE,
259 ),
260 ],
261 )
262 def test_legacy_result_uses_source_specific_outcome(source, detail, expected):
263 artifact = pipeline._result_outcome_artifact(source, {"error": detail})
264
265 assert artifact["_source_outcome"]["state"] == expected
266
267
268 def test_captured_http_failure_overrides_generic_artifact_error():
269 failure = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
270 outcome = pipeline._resolve_stream_outcome(
271 "tiktok",
272 pipeline._outcome_artifact(health.ERROR, "request failed"),
273 [failure],
274 )
275
276 assert outcome["state"] == schema.RATE_LIMITED
277
278
279 def test_bundle_records_items_then_429_as_partial():
280 item = schema.SourceItem(
281 item_id="x1",
282 source="x",
283 title="A post",
284 body="body",
285 url="https://x.com/example/status/1",
286 )
287 bundle = schema.RetrievalBundle()
288 bundle.mark_attempted("x")
289 bundle.add_items("primary", "x", [item])
290 bundle.record_failure("x", schema.RATE_LIMITED, "429 after first page")
291
292 outcome = bundle.source_status["x"]
293 assert outcome.state == schema.PARTIAL
294 assert outcome.items_returned == 1
295 assert outcome.detail == "429 after first page"
296
297
298 def test_pipeline_records_clean_empty_source_as_no_results():
299 plan = {
300 "intent": "general",
301 "freshness_mode": "balanced_recent",
302 "cluster_mode": "story",
303 "subqueries": [
304 {
305 "label": "primary",
306 "search_query": "test topic",
307 "ranking_query": "test topic",
308 "sources": ["x"],
309 }
310 ],
311 "source_weights": {"x": 1.0},
312 }
313 with patch("lib.pipeline._retrieve_stream", return_value=([], {})):
314 report = pipeline.run(
315 topic="test topic",
316 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
317 depth="quick",
318 requested_sources=["x"],
319 mock=True,
320 external_plan=plan,
321 )
322
323 assert report.source_status["x"].state == schema.NO_RESULTS
324 assert "x" not in report.errors_by_source
325
326
327 def _perplexity_plan():
328 return {
329 "intent": "general",
330 "freshness_mode": "balanced_recent",
331 "cluster_mode": "story",
332 "subqueries": [
333 {
334 "label": "primary",
335 "search_query": "test topic",
336 "ranking_query": "test topic",
337 "sources": ["perplexity"],
338 }
339 ],
340 "source_weights": {"perplexity": 1.0},
341 }
342
343
344 def test_pipeline_records_both_mode_semantic_leg_failure_as_partial():
345 raw_item = {
346 "id": "PXS1",
347 "title": "Search result",
348 "url": "https://example.com/result",
349 "snippet": "Raw search evidence",
350 # Inside the pinned as_of window below. A wall-clock relative date
351 # (today-5) falls outside 2026-07-21..2026-08-20 once the calendar
352 # moves past late August, and the failure then records as ERROR.
353 "date": "2026-08-15",
354 "relevance": 0.8,
355 "why_relevant": "Perplexity Search result",
356 "engagement": {},
357 }
358 artifact = {
359 "mode": "both",
360 "search": {"mode": "search"},
361 "agent": {
362 "error": "failed",
363 "agentErrorMessage": "Provider rejected synthesis",
364 },
365 "itemCount": 1,
366 }
367 with patch("lib.pipeline._retrieve_stream_impl", return_value=([raw_item], artifact)):
368 report = pipeline.run(
369 topic="test topic",
370 config={
371 "LAST30DAYS_REASONING_PROVIDER": "gemini",
372 "PERPLEXITY_API_KEY": "pplx-test",
373 },
374 depth="quick",
375 lookback_days=30,
376 as_of_date="2026-08-20",
377 requested_sources=["perplexity"],
378 mock=True,
379 external_plan=_perplexity_plan(),
380 )
381
382 assert report.range_from == "2026-07-21"
383 assert report.range_to == "2026-08-20"
384 outcome = report.source_status["perplexity"]
385 assert outcome.state == schema.PARTIAL
386 assert outcome.items_returned == 1
387 assert outcome.detail == "agent leg: Provider rejected synthesis"
388
389
390 def test_pipeline_records_both_mode_semantic_failure_without_items():
391 artifact = {
392 "mode": "both",
393 "search": {"mode": "search"},
394 "agent": {
395 "error": "failed",
396 "agentErrorMessage": "Provider rejected synthesis",
397 },
398 "itemCount": 0,
399 }
400 with patch("lib.pipeline._retrieve_stream_impl", return_value=([], artifact)):
401 report = pipeline.run(
402 topic="test topic",
403 config={
404 "LAST30DAYS_REASONING_PROVIDER": "gemini",
405 "PERPLEXITY_API_KEY": "pplx-test",
406 },
407 depth="quick",
408 requested_sources=["perplexity"],
409 mock=True,
410 external_plan=_perplexity_plan(),
411 )
412
413 outcome = report.source_status["perplexity"]
414 assert outcome.state == health.ERROR
415 assert outcome.items_returned == 0
416 assert outcome.detail == "agent leg: Provider rejected synthesis"
417
418
419 def test_pipeline_preserves_typed_http_failure():
420 plan = {
421 "intent": "general",
422 "freshness_mode": "balanced_recent",
423 "cluster_mode": "story",
424 "subqueries": [
425 {
426 "label": "primary",
427 "search_query": "test topic",
428 "ranking_query": "test topic",
429 "sources": ["x"],
430 }
431 ],
432 "source_weights": {"x": 1.0},
433 }
434 failure = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
435 with patch("lib.pipeline._retrieve_stream", side_effect=failure):
436 report = pipeline.run(
437 topic="test topic",
438 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
439 depth="quick",
440 requested_sources=["x"],
441 mock=True,
442 external_plan=plan,
443 )
444
445 assert report.source_status["x"].state == schema.RATE_LIMITED
446 assert report.source_status["x"].items_returned == 0
447 assert "x" in report.errors_by_source
448
449
450 def test_footer_and_synthesis_note_surface_failed_source():
451 report = _report(
452 source_status={
453 "x": schema.SourceOutcome(
454 source="x",
455 state=schema.RATE_LIMITED,
456 detail="HTTP 429 after retry budget",
457 fix_hint="doctor",
458 )
459 },
460 errors_by_source={"x": "HTTP 429 after retry budget"},
461 )
462
463 text = render.render_compact(report)
464
465 # A failed source that returned zero items is surfaced to synthesis via the
466 # evidence blocks (## Partial Coverage), NOT as a user-facing footer line -
467 # zero-item sources are dropped from the emoji tree (see test_render_footer).
468 assert "## Partial Coverage" in text
469 assert "Do not interpret a failed source as no discussion" in text
470 assert "🔵 X: rate-limited: HTTP 429 after retry budget (run doctor for fixes)" not in text
471
472
473 def test_report_source_status_round_trips_through_schema_serialization():
474 report = _report(
475 source_status={
476 "x": schema.SourceOutcome(
477 source="x",
478 state=schema.PARTIAL,
479 items_returned=12,
480 detail="429 after 12 items",
481 at="2026-07-10T18:22:03Z",
482 fix_hint="doctor",
483 )
484 }
485 )
486
487 payload = schema.to_dict(report)
488 restored = schema.report_from_dict(payload)
489
490 assert payload["source_status"]["x"]["state"] == schema.PARTIAL
491 assert restored.source_status["x"] == report.source_status["x"]
492
493
494 # --- strict exit (LAST30DAYS_STRICT_EXIT, issue #384) ---
495
496 import last30days as cli
497
498
499 def _outcome(source, state, **kwargs):
500 return schema.SourceOutcome(source=source, state=state, **kwargs)
501
502
503 def test_strict_exit_disabled_by_default_even_when_degraded():
504 report = _report(
505 source_status={"x": _outcome("x", schema.RATE_LIMITED, detail="429")}
506 )
507 assert cli._strict_exit_code(report, None, {}) == 0
508
509
510 def test_strict_exit_returns_3_for_degraded_run(capsys):
511 report = _report(
512 source_status={"x": _outcome("x", schema.AUTH_FAILED, detail="401")}
513 )
514 rc = cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "1"})
515 assert rc == 3
516 assert "strict-exit: degraded sources: x" in capsys.readouterr().err
517
518
519 def test_strict_exit_treats_payment_required_as_degraded(capsys):
520 report = _report(
521 source_status={
522 "x": _outcome("x", schema.PAYMENT_REQUIRED, detail="xapi: payment required")
523 }
524 )
525 rc = cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "1"})
526 assert rc == 3
527 assert "strict-exit: degraded sources: x" in capsys.readouterr().err
528
529
530 def test_strict_exit_clean_states_return_0():
531 report = _report(
532 source_status={
533 "reddit": _outcome("reddit", health.OK, items_returned=12),
534 "hn": _outcome("hn", schema.NO_RESULTS),
535 "tiktok": _outcome("tiktok", schema.SKIPPED_UNCONFIGURED, attempted=False),
536 }
537 )
538 assert cli._strict_exit_code(report, None, {"LAST30DAYS_STRICT_EXIT": "true"}) == 0
539
540
541 def test_strict_exit_checks_entity_reports_in_comparison_runs():
542 lead = _report(source_status={"reddit": _outcome("reddit", health.OK)})
543 entity = _report(
544 source_status={"x": _outcome("x", schema.UNREACHABLE, detail="dns")}
545 )
546 rc = cli._strict_exit_code(lead, [("other", entity)], {"LAST30DAYS_STRICT_EXIT": "on"})
547 assert rc == 3
548
549
550 def test_strict_exit_env_key_is_registered():
551 # Unregistered keys are silently dropped by env config loading (#707 class).
552 from lib import env as env_module
553 import inspect
554
555 assert "LAST30DAYS_STRICT_EXIT" in inspect.getsource(env_module)
556
557
558 def test_captured_failure_selection_prefers_most_specific():
559 auth = http.HTTPError("HTTP 401: Unauthorized", status_code=401)
560 rate = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
561 # Order must not matter: auth-failed wins over rate-limited either way.
562 for failures in ([auth, rate], [rate, auth]):
563 outcome = pipeline._resolve_stream_outcome("x", None, failures)
564 assert outcome["state"] == schema.AUTH_FAILED
565
566
567 def test_bundle_keeps_ok_with_lane_detail_across_subqueries():
568 """A swallowed lane failure on a source that delivered items stays ``ok``
569 and carries the loss as detail; a later clean subquery keeps that detail."""
570 item = schema.SourceItem(
571 item_id="r1",
572 source="reddit",
573 title="A thread",
574 body="body",
575 url="https://www.reddit.com/r/test/comments/abc/",
576 )
577 bundle = schema.RetrievalBundle()
578 bundle.mark_attempted("reddit")
579 bundle.record_detail("reddit", "3 sub-requests rate-limited (HTTP 429)")
580 bundle.add_items("primary", "reddit", [item])
581
582 outcome = bundle.source_status["reddit"]
583 assert outcome.state == health.OK
584 assert outcome.items_returned == 1
585 assert outcome.detail == "3 sub-requests rate-limited (HTTP 429)"
586 assert outcome.fix_hint is None
587
588 bundle.add_items("secondary", "reddit", [])
589 outcome = bundle.source_status["reddit"]
590 assert outcome.state == health.OK
591 assert outcome.detail == "3 sub-requests rate-limited (HTTP 429)"
592
593
594 def test_finalize_turns_an_empty_ok_source_with_lane_failures_into_that_failure():
595 """Zero items after filtering plus swallowed 429s is not 'completed cleanly
596 with zero matches'; it is the rate limit, so ## Partial Coverage fires and
597 doctor does not list the source as succeeded."""
598 bundle = schema.RetrievalBundle()
599 bundle.mark_attempted("polymarket")
600 bundle.record_detail("polymarket", "5 sub-requests rate-limited (HTTP 429)", state=schema.RATE_LIMITED)
601 bundle.add_items("primary", "polymarket", [])
602 finalized = pipeline._finalize_source_status(bundle.source_status, {"polymarket": []})
603 outcome = finalized["polymarket"]
604 assert outcome.state == schema.RATE_LIMITED
605 assert outcome.detail == "5 sub-requests rate-limited (HTTP 429)"
606 assert outcome.items_returned == 0
607
608
609 # --- payment-required: credit exhaustion is not an auth failure (KTD6) ---
610
611
612 def test_classify_failure_402_status_is_payment_required():
613 assert http.classify_failure(status_code=402) == schema.PAYMENT_REQUIRED
614 # A 402 with a non-JSON / empty body still classifies by status alone.
615 err = http.HTTPError("HTTP 402: Payment Required", 402, "<html>upgrade</html>")
616 assert err.outcome_state == schema.PAYMENT_REQUIRED
617
618
619 @pytest.mark.parametrize(
620 "message",
621 [
622 "Your enrolled account does not have any credits",
623 "insufficient credits",
624 "ScrapeCreators: Insufficient credits remaining",
625 "xapi: payment required (X API credits exhausted)",
626 "Xquik key unpaid: payment required (402)",
627 "You are out of credits for this billing period",
628 ],
629 )
630 def test_classify_failure_credit_exhaustion_markers(message):
631 assert http.classify_failure(message=message) == schema.PAYMENT_REQUIRED
632
633
634 def test_classify_failure_bare_word_credits_is_not_a_marker():
635 # The onboarding copy mentions "10,000 free credits"; a message that merely
636 # contains the word must not be branded as credit exhaustion.
637 state = http.classify_failure(message="Sign up for 10,000 free credits")
638 assert state != schema.PAYMENT_REQUIRED
639
640
641 @pytest.mark.parametrize("status", [401, 403])
642 def test_classify_failure_401_403_stay_auth_failed(status):
643 assert http.classify_failure(status_code=status) == schema.AUTH_FAILED
644 assert http.classify_failure(message=f"HTTP {status}: nope") == schema.AUTH_FAILED
645
646
647 def test_payment_required_wins_over_auth_marker_in_same_message():
648 # A credit-exhaustion message that also carries an auth word is about
649 # money, not identity: the payment branch runs before the 401/403 branch.
650 state = http.classify_failure(
651 status_code=403,
652 message="Forbidden: your enrolled account does not have any credits",
653 )
654 assert state == schema.PAYMENT_REQUIRED
655
656
657 def test_cross_source_insufficient_credits_reclassifies_from_error():
658 # ScrapeCreators-backed sources surface credit exhaustion as a plain
659 # legacy error string; the shared classifier now types it.
660 artifact = pipeline._result_outcome_artifact(
661 "tiktok", {"items": [], "error": "ScrapeCreators: insufficient credits"}
662 )
663 assert artifact["_source_outcome"]["state"] == schema.PAYMENT_REQUIRED
664 state, attempted = pipeline._classify_source_failure(
665 http.HTTPError("HTTP 402: insufficient credits", status_code=402)
666 )
667 assert (state, attempted) == (schema.PAYMENT_REQUIRED, True)
668
669
670 def test_captured_failure_selection_ranks_payment_required_above_rate_limit():
671 pay = http.HTTPError("HTTP 402: Payment Required", status_code=402)
672 rate = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
673 for failures in ([pay, rate], [rate, pay]):
674 outcome = pipeline._resolve_stream_outcome("x", None, failures)
675 assert outcome["state"] == schema.PAYMENT_REQUIRED
676
677
678 def test_lane_failure_summary_names_credit_exhaustion():
679 text = pipeline._summarize_lane_failures(
680 [http.HTTPError("HTTP 402: Payment Required", status_code=402)]
681 )
682 assert text == "1 sub-request credits exhausted (HTTP 402)"
683
684
685 def test_render_summary_labels_payment_required_per_source():
686 x_outcome = schema.SourceOutcome(
687 source="x", state=schema.PAYMENT_REQUIRED, detail="xapi: payment required"
688 )
689 other = schema.SourceOutcome(
690 source="tiktok", state=schema.PAYMENT_REQUIRED, detail="insufficient credits"
691 )
692 x_text = render._format_outcome(x_outcome)
693 assert x_text.startswith("X API credits exhausted")
694 assert "xapi: payment required" in x_text
695 assert render._format_outcome(other).startswith("credits exhausted")
696 # The Partial Coverage note carries the label too.
697 report = _report(source_status={"x": x_outcome, "tiktok": other})
698 note = "\n".join(render._render_source_outcome_note(report))
699 assert "X API credits exhausted" in note
700 assert "credits exhausted" in note
701
702
703 def test_postmortem_labels_payment_required():
704 from lib import doctor
705
706 pm = {
707 "engine_version": "test",
708 "mode": "postmortem",
709 "present": True,
710 "topic": "t",
711 "at": "2026-07-10T18:22:03Z",
712 "outcomes": {
713 "x": schema.to_dict(
714 schema.SourceOutcome(
715 source="x",
716 state=schema.PAYMENT_REQUIRED,
717 detail="xapi: payment required (X API credits exhausted)",
718 )
719 ),
720 "tiktok": schema.to_dict(
721 schema.SourceOutcome(
722 source="tiktok",
723 state=schema.PAYMENT_REQUIRED,
724 detail="insufficient credits",
725 )
726 ),
727 },
728 }
729 text = doctor.render_postmortem_text(pm)
730 assert "Failed:" in text
731 assert "x — X API credits exhausted" in text
732 assert "tiktok — credits exhausted" in text
733 assert "auth-failed" not in text
734
735
736 def test_json_export_carries_payment_required():
737 report = _report(
738 source_status={"x": _outcome("x", schema.PAYMENT_REQUIRED, detail="402")}
739 )
740 assert schema.to_agent_export(report)["source_status"]["x"] == "payment-required"
741 restored = schema.report_from_dict(schema.to_dict(report))
742 assert restored.source_status["x"].state == schema.PAYMENT_REQUIRED
743
743 lines PYTHON