Improve runtime resilience and flightwatch fallback (#202)
* Add ADSB.lol fallback for rare-aircraft scans
* Keep flight observation attribution accurate on fallback
* Add DCA and PNS airport aliases
* Avoid pytest collection exit in companion tests
* Test rare-aircraft provider fallback
* Run flightwatch regression tests before deployment
* Add pull request regression gate for runtime fixes
* Let pull request regression checks run beside scheduled deploys
Full diff
@@ -4,17 +4,48 @@ on:\n schedule:\n - cron: "1 * * * *" # hourly; 16:01 UTC is 00:01 Asia/Taipei\n workflow_dispatch:\n+ pull_request:\n+ branches: [main]\n+ paths:\n+ - ".github/workflows/hourly.yml"\n+ - "pipeline/adsb.py"\n+ - "pipeline/flightwatch.py"\n+ - "pipeline/flightnews.py"\n+ - "pipeline/airport_codes.py"\n+ - "config/airport_iata_aliases.json"\n+ - "tests/test_companion.py"\n+ - "tests/test_flightwatch.py"\n+ - "tests/test_airport_codes.py"\n push:\n branches: [main]\n paths-ignore:\n - "data/**" # bot data commits must not retrigger the workflow\n \n concurrency:\n- group: hourly\n+ group: ${{ github.event_name == 'pull_request' && format('hourly-pr-{0}', github.event.pull_request.number) || 'hourly' }}\n cancel-in-progress: false\n \n jobs:\n+ regression:\n+ if: github.event_name == 'pull_request'\n+ runs-on: ubuntu-latest\n+ permissions:\n+ contents: read\n+ steps:\n+ - uses: actions/checkout@v5\n+ - uses: actions/setup-python@v6\n+ with:\n+ python-version: "3.12"\n+ cache: pip\n+ - run: pip install -r requirements.txt\n+ - name: Run pipeline regression tests\n+ run: |\n+ python tests/test_companion.py\n+ python tests/test_flightwatch.py\n+ python tests/test_airport_codes.py\n+\n update:\n+ if: github.event_name != 'pull_request'\n runs-on: ubuntu-latest\n permissions:\n contents: write # commits data/ back to the repo\n@@ -40,6 +71,11 @@ jobs:\n - name: Test summary boundary guard\n run: python tests/test_summary_guard.py\n \n+ - name: Test source enrichment and flightwatch resilience\n+ run: |\n+ python tests/test_companion.py\n+ python tests/test_flightwatch.py\n+\n - name: Test headline priority and subject scope\n run: python tests/test_headline_rotation.py\n \n@@ -7,6 +7,8 @@\n "Chicago O'Hare": "ORD",\n "O'Hare": "ORD",\n "芝加哥奧黑爾": "ORD",\n+ "Ronald Reagan Washington National Airport": "DCA",\n+ "Pensacola International Airport": "PNS",\n "Philadelphia International Airport": "PHL",\n "Northeast Philadelphia Airport": "PNE",\n "Atlantic City Bader Field": "AIY",\n@@ -128,12 +128,15 @@ def assemble_source(event: dict) -> str:\n operator = _operator_details(event)\n arrival = event.get("arrival") or {}\n rar = event.get("rarity") or {}\n+ primary_provider = str(event.get("primaryProvider") or "Airplanes.live")\n+ secondary_provider = ("ADSB.lol"\n+ if primary_provider != "ADSB.lol" else "未使用獨立第二來源")\n lines = [\n '<SOURCE type="flight_observation">',\n f"事件 ID:{event.get('eventId')}",\n "資料類型:公開 ADS-B 觀測",\n- "主要資料來源:Airplanes.live",\n- "第二資料來源:ADSB.lol",\n+ f"主要資料來源:{primary_provider}",\n+ f"第二資料來源:{secondary_provider}",\n f"交叉確認狀態:{event.get('crossCheck')}",\n f"機場 ICAO:{airport.get('icao')}",\n f"機場名稱:{airport.get('name_zh')}",\n@@ -438,17 +438,31 @@ def main() -> None:\n "duplicate_events_skipped", "secondary_confirmations",\n "conflicting_sources", "queued_news_events")}\n \n- # ONE wide-area scan per run; failure leaves every file untouched.\n+ # Prefer Airplanes.live, but fail over to ADSB.lol for the wide-area\n+ # scan. A single provider outage must not silently blind the monitor.\n+ # If both providers fail, preserve every state file: unknown sky is\n+ # never interpreted as an empty sky.\n+ scan_provider = "Airplanes.live"\n stats["provider_requests"] += 1\n try:\n raw_rows = adsb.airplanes_live_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\n- except adsb.ProviderDown as exc:\n+ except adsb.ProviderDown as primary_exc:\n stats["provider_failures"] += 1\n- print(f"flightwatch: primary provider unavailable ({exc}); "\n- "state untouched - an API failure is NOT an empty sky")\n- print("flightwatch stats: "\n- + " ".join(f"{k}={v}" for k, v in stats.items()))\n- return\n+ print(f"flightwatch: Airplanes.live unavailable ({primary_exc}); "\n+ "trying ADSB.lol wide-area fallback")\n+ stats["provider_requests"] += 1\n+ try:\n+ raw_rows = adsb.adsb_lol_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\n+ scan_provider = "ADSB.lol"\n+ print("flightwatch: ADSB.lol wide-area fallback active")\n+ except adsb.ProviderDown as fallback_exc:\n+ stats["provider_failures"] += 1\n+ print(f"flightwatch: both scan providers unavailable "\n+ f"(Airplanes.live: {primary_exc}; ADSB.lol: {fallback_exc}); "\n+ "state untouched - API failure is NOT an empty sky")\n+ print("flightwatch stats: "\n+ + " ".join(f"{k}={v}" for k, v in stats.items()))\n+ return\n stats["aircraft_returned"] = len(raw_rows)\n \n state = _load_state()\n@@ -553,14 +567,19 @@ def main() -> None:\n continue\n stats["rarity_candidates"] += 1\n \n- # Secondary source ONLY now, for this one candidate.\n- stats["provider_requests"] += 1\n- try:\n- secondary = adsb.adsb_lol_icao(ac["hex"])\n- cross = cross_check(ac, secondary)\n- except adsb.ProviderDown:\n- stats["provider_failures"] += 1\n- cross = "secondary_unavailable"\n+ # Cross-check only when ADSB.lol is independent of the wide-area\n+ # scan. If ADSB.lol already rescued the scan, do not pretend that a\n+ # second request to the same provider is independent confirmation.\n+ if scan_provider == "ADSB.lol":\n+ cross = "confirmed_by_primary_only"\n+ else:\n+ stats["provider_requests"] += 1\n+ try:\n+ secondary = adsb.adsb_lol_icao(ac["hex"])\n+ cross = cross_check(ac, secondary)\n+ except adsb.ProviderDown:\n+ stats["provider_failures"] += 1\n+ cross = "secondary_unavailable"\n if cross == "confirmed_by_two_sources":\n stats["secondary_confirmations"] += 1\n elif cross == "conflicting_sources":\n@@ -581,6 +600,7 @@ def main() -> None:\n "notBeforeUtc": iso(now + timedelta(\n minutes=PUBLICATION_DELAY_MIN)),\n "bootstrap": bootstrap,\n+ "primaryProvider": scan_provider,\n "crossCheck": cross,\n "airport": {k: airport[k] for k in\n ("icao", "iata", "name_zh", "name_en")},\n@@ -166,4 +166,5 @@ def get(self, url, **kwargs):\n print(f"\n{CHECKS} checks passed, {FAILED} failed"\n if not FAILED else f"\n{CHECKS - FAILED}/{CHECKS} passed, "\n f"{FAILED} FAILED")\n-sys.exit(1 if FAILED else 0)\n+if __name__ == "__main__":\n+ sys.exit(1 if FAILED else 0)\n@@ -509,6 +509,39 @@ def test_flight_no_candidates_no_ai():\n print("test_flight_no_candidates_no_ai: done")\n \n \n+def test_scan_provider_fallback():\n+ _reset_data()\n+ original_primary = adsb.airplanes_live_point\n+ original_fallback = adsb.adsb_lol_point\n+ original_enabled = os.environ.get("FLIGHT_TRACKING_ENABLED")\n+ calls = {"primary": 0, "fallback": 0}\n+\n+ def primary(*_args, **_kwargs):\n+ calls["primary"] += 1\n+ raise adsb.ProviderDown("HTTP 403")\n+\n+ def fallback(*_args, **_kwargs):\n+ calls["fallback"] += 1\n+ return []\n+\n+ adsb.airplanes_live_point = primary\n+ adsb.adsb_lol_point = fallback\n+ os.environ["FLIGHT_TRACKING_ENABLED"] = "true"\n+ try:\n+ flightwatch.main()\n+ finally:\n+ adsb.airplanes_live_point = original_primary\n+ adsb.adsb_lol_point = original_fallback\n+ if original_enabled is None:\n+ os.environ.pop("FLIGHT_TRACKING_ENABLED", None)\n+ else:\n+ os.environ["FLIGHT_TRACKING_ENABLED"] = original_enabled\n+\n+ check(calls == {"primary": 1, "fallback": 1},\n+ "Airplanes.live failure triggers exactly one ADSB.lol wide-area fallback")\n+ print("test_scan_provider_fallback: done")\n+\n+\n def test_flight_bad_quote_and_reject():\n _reset_data()\n flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\n@@ -550,6 +583,7 @@ def main():\n test_source_assembly_privacy,\n test_flight_events_auto_publish,\n test_flight_fallback_history,\n+ test_scan_provider_fallback,\n test_flight_gate_rejects_conflicts_and_publishes_reliable_risk,\n test_flight_no_candidates_no_ai,\n test_flight_bad_quote_and_reject,