Skip to content

Commit b93fc59

Browse files
fix: fail fast on provider-wide X outage (#61)
1 parent 6f5a85e commit b93fc59

8 files changed

Lines changed: 127 additions & 2 deletions

app/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,13 @@ async def run_scheduled_scan(self) -> None:
491491
self.state.data["last_auto_attempt"] = now.isoformat()
492492
lookback = max(2, int(self.settings.runtime.get("scheduled_lookback_hours", 24)))
493493
start = max(last - timedelta(minutes=30), now - timedelta(hours=lookback))
494+
if getattr(self.collector, "provider_preflight_blocked", lambda: False)():
495+
logger.warning(
496+
"Scheduled X scan skipped because the immediately preceding provider probe was offline."
497+
)
498+
self.state.data["last_failed_sources"] = list(self.collector.last_errors)[:10]
499+
self._record_x_scan_failure(now)
500+
return
494501
try:
495502
updates = await self.collector.collect_window(start, now, max_per_query=200)
496503
except XCollectionError as exc:

app/production_preflight.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import asyncio
44
import logging
5+
import os
6+
from pathlib import Path
57

68
from .config import ConfigError, Settings
79
from .telegram import TelegramBot, TelegramError
@@ -10,6 +12,17 @@
1012
logger = logging.getLogger(__name__)
1113

1214

15+
def _publish_github_provider_state(report: dict[str, str]) -> None:
16+
"""Expose the live X probe to later steps without making X a hard dependency."""
17+
github_env = os.environ.get("GITHUB_ENV", "").strip()
18+
if not github_env:
19+
return
20+
x_status = str(report.get("x", "offline (missing status)"))
21+
state = "online" if x_status == "ok" else "offline"
22+
with Path(github_env).open("a", encoding="utf-8") as stream:
23+
stream.write(f"X_PROVIDER_PREFLIGHT={state}\n")
24+
25+
1326
def _check_telegram(settings: Settings) -> str:
1427
"""Fail closed only when the assistant cannot receive or send messages."""
1528
telegram = TelegramBot(
@@ -92,6 +105,7 @@ def main() -> int:
92105

93106
for provider, status in report.items():
94107
print(f"Production preflight: {provider}={status}")
108+
_publish_github_provider_state(report)
95109
return 0
96110

97111

app/webhook_aware_assistant.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ async def run_scheduled_scan(self) -> None:
168168

169169
lookback = max(2, int(self.settings.runtime.get("scheduled_lookback_hours", 24)))
170170
start = max(last - timedelta(minutes=30), now - timedelta(hours=lookback))
171+
if getattr(self.collector, "provider_preflight_blocked", lambda: False)():
172+
logger.warning(
173+
"Scheduled X scan skipped because the immediately preceding provider probe was offline."
174+
)
175+
self.state.data["last_failed_sources"] = list(self.collector.last_errors)[:10]
176+
self._record_x_scan_failure(now)
177+
return
171178
try:
172179
updates = await self.collector.collect_window(start, now, max_per_query=200)
173180
except XCollectionError as exc:

app/x_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import logging
5+
import os
56
import re
67
from datetime import datetime, timedelta, timezone
78
from pathlib import Path
@@ -138,6 +139,20 @@ def __init__(self, cookies: dict[str, str], sources: list[dict[str, Any]], keywo
138139
and str(item.get("mode", "")).strip().lower() == SourceMode.FULL_FEED.value
139140
}
140141

142+
def provider_preflight_blocked(self) -> bool:
143+
"""Stop a known provider-wide outage before retrying every source."""
144+
if os.environ.get("X_PROVIDER_PREFLIGHT", "").strip().lower() != "offline":
145+
return False
146+
errors: list[str] = []
147+
for source in self.sources:
148+
if not source.get("enabled", True):
149+
continue
150+
handle = normalize_handle(str(source.get("handle", "")))
151+
if handle:
152+
errors.append(f"@{handle}: provider_preflight_offline")
153+
self.last_errors = errors or ["provider_preflight_offline"]
154+
return True
155+
141156
async def _get_api(self):
142157
if self.api is not None:
143158
return self.api

docs/LAUNCH_STATUS.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
# Production Launch Status
22

3-
آخرین به‌روزرسانی: 2026-08-12
3+
آخرین به‌روزرسانی: 2026-09-07
4+
5+
## هشدار عملیاتی فعلی
6+
7+
- اجرای اصلی Daily روی GitHub Actions از 2026-09-06/07 در لبهٔ `x.com` با پاسخ
8+
Cloudflare `403` روبه‌رو است؛ این failure پیش از دریافت GraphQL و مستقل از اعتبار cookie
9+
مشاهده شده است.
10+
- Nightly Fanfic Digest در همین بازه سالم اجرا شده، بنابراین outage به runtime اصلی X
11+
محدود است و به‌عنوان COMPLETE یا پنجرهٔ خالی معتبر ثبت نمی‌شود.
12+
- نسخهٔ curl/browser-TLS کتابخانه نیز روی GitHub-hosted runner همان Cloudflare challenge را
13+
دریافت کرده است؛ بنابراین ارتقای dependency به‌تنهایی recovery محسوب نمی‌شود.
14+
- تا زمان اثبات یک egress سالم، preflight آفلاین باید اسکن X را fail-fast کند، cursor را جلو
15+
نبرد و همهٔ منابع را صریحاً failed/unproven نگه دارد. آزمایش میزبان جایگزین فقط به‌صورت
16+
ناشناس و بدون انتقال cookie/secret انجام می‌شود.
417

518
## وضعیت
619

tests/test_audit_regressions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,23 @@ def test_explicit_live_part_numbers_override_bad_timestamps(self):
135135

136136

137137
class XAuditTests(unittest.TestCase):
138+
def test_offline_provider_preflight_names_every_enabled_source(self):
139+
collector = XCollector(
140+
{},
141+
[
142+
{"handle": "alpha", "enabled": True},
143+
{"handle": "beta", "enabled": True},
144+
{"handle": "disabled", "enabled": False},
145+
],
146+
[],
147+
)
148+
with patch.dict(os.environ, {"X_PROVIDER_PREFLIGHT": "offline"}):
149+
self.assertTrue(collector.provider_preflight_blocked())
150+
self.assertEqual(
151+
collector.last_errors,
152+
["@alpha: provider_preflight_offline", "@beta: provider_preflight_offline"],
153+
)
154+
138155
def test_keyword_synonyms_are_combined_into_one_query_per_group(self):
139156
queries = _keyword_queries(
140157
[

tests/test_production_preflight.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import os
5+
import tempfile
46
import unittest
7+
from pathlib import Path
58
from types import SimpleNamespace
69
from unittest.mock import AsyncMock, patch
710

811
from app.config import ConfigError
9-
from app.production_preflight import _check_gemini, _check_telegram, _check_x
12+
from app.production_preflight import (
13+
_check_gemini,
14+
_check_telegram,
15+
_check_x,
16+
_publish_github_provider_state,
17+
)
1018
from app.telegram import TelegramPermanentError
1119
from app.x_client import XCollectionError
1220

@@ -27,6 +35,20 @@ def settings(**overrides):
2735

2836

2937
class ProductionPreflightTests(unittest.TestCase):
38+
def test_github_env_records_offline_x_without_error_detail(self):
39+
with tempfile.TemporaryDirectory() as temp:
40+
path = Path(temp) / "github-env"
41+
with patch.dict(os.environ, {"GITHUB_ENV": str(path)}):
42+
_publish_github_provider_state({"x": "offline (XCollectionError)"})
43+
self.assertEqual(path.read_text(encoding="utf-8"), "X_PROVIDER_PREFLIGHT=offline\n")
44+
45+
def test_github_env_records_online_x(self):
46+
with tempfile.TemporaryDirectory() as temp:
47+
path = Path(temp) / "github-env"
48+
with patch.dict(os.environ, {"GITHUB_ENV": str(path)}):
49+
_publish_github_provider_state({"x": "ok"})
50+
self.assertEqual(path.read_text(encoding="utf-8"), "X_PROVIDER_PREFLIGHT=online\n")
51+
3052
@patch("app.production_preflight.TelegramBot")
3153
def test_telegram_is_the_hard_dependency(self, bot_class):
3254
bot_class.return_value.api.side_effect = TelegramPermanentError("bad token")

tests/test_webhook_runtime.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,36 @@ def test_partial_x_attempt_is_persistently_throttled_between_action_passes(self)
7878

7979
app.collector.collect_window.assert_not_awaited()
8080

81+
def test_provider_wide_preflight_outage_skips_collection_without_advancing_cursor(self):
82+
now = datetime.now(timezone.utc)
83+
previous_cursor = (now - timedelta(hours=1)).isoformat()
84+
app = object.__new__(WebhookAwarePersonalAssistant)
85+
app.state = SimpleNamespace(
86+
data={
87+
"last_auto_run": previous_cursor,
88+
"last_auto_attempt": (now - timedelta(minutes=30)).isoformat(),
89+
}
90+
)
91+
app.settings = SimpleNamespace(
92+
runtime={"scheduled_min_interval_minutes": 12, "scheduled_lookback_hours": 24}
93+
)
94+
app.collector = SimpleNamespace(
95+
provider_preflight_blocked=Mock(return_value=True),
96+
collect_window=AsyncMock(),
97+
last_errors=["@alpha: provider_preflight_offline"],
98+
)
99+
app._notify_x_failure_if_due = Mock()
100+
101+
asyncio.run(app.run_scheduled_scan())
102+
103+
app.collector.collect_window.assert_not_awaited()
104+
self.assertEqual(app.state.data["last_auto_run"], previous_cursor)
105+
self.assertEqual(app.state.data["x_scan_failure_streak"], 1)
106+
self.assertEqual(
107+
app.state.data["last_failed_sources"],
108+
["@alpha: provider_preflight_offline"],
109+
)
110+
81111
def test_partial_source_fetch_queues_recovery_but_does_not_advance_success_cursor(self):
82112
now = datetime.now(timezone.utc)
83113
previous_cursor = (now - timedelta(hours=1)).isoformat()

0 commit comments

Comments
 (0)