From 4b2c349db92b4967fb17ffe0b943e0f1917db301 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Tue, 28 Jul 2026 00:04:49 -0300 Subject: [PATCH 1/3] fix: recover from FR24 live-feed backends that return no flights FR24 balances zones/fcgi/feed.js across several backends and at least one of them answers HTTP 200 with a well-formed envelope -- full_count set, stats.total populated -- but no flight entries and stats.visible zeroed. Roughly half the requests landed on it, and its full_count stayed frozen at the same value for over 20 minutes while a healthy backend kept ticking up. Two things turned that into a deterministic failure. The AWSALB cookie pins the session to whichever backend it first hit, so a client that landed on the degraded one returned an empty flight list for the rest of its lifetime -- which is why every flight test failed together while the airport and airline tests passed. And the degraded payload is structurally identical to a legitimately empty result (a bounds box over open ocean), so no field in the response can tell the two apart. getFlights()/get_flights() now treat zero entries with full_count > 0 as suspect: drop the stickiness cookies so the balancer re-rolls the backend, then re-request, up to four times. A successful response re-pins the session to the healthy backend, so the cost is paid at most once per client. A genuinely empty query still returns an empty list. Shedding stickiness needs a targeted cookie delete: clearCookies() would also drop _frPl, the login cookie, which shares the same jar. Hence the new deleteCookie/delete_cookie on the client. Covered by new offline tests in both SDKs (retry-then-succeed, bounded give-up, no retry on full_count 0, no retry when healthy, filters preserved across retries, login cookie survives), so the PR gate keeps guarding this while FR24 is healthy. --- nodejs/FlightRadarAPI/api.js | 36 +++++--- nodejs/FlightRadarAPI/index.d.ts | 2 + nodejs/FlightRadarAPI/request.js | 21 +++++ nodejs/package.json | 2 +- nodejs/tests/testFeedRetry.js | 136 ++++++++++++++++++++++++++++ python/FlightRadarAPI/api.py | 42 ++++++--- python/FlightRadarAPI/request.py | 11 +++ python/tests/test_feed_retry.py | 146 +++++++++++++++++++++++++++++++ 8 files changed, 368 insertions(+), 28 deletions(-) create mode 100644 nodejs/tests/testFeedRetry.js create mode 100644 python/tests/test_feed_retry.py diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index 3a27623..19e92a6 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -27,6 +27,13 @@ async function mapConcurrent(items, concurrency, fn) { await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); } +// Some FR24 live-feed backends answer 200 with a well-formed envelope but no +// flight entries — indistinguishable from a legitimately empty result. The +// `AWSALB` cookie then pins the session to that backend, so dropping it is what +// makes the load balancer re-roll on retry. +const FEED_STICKY_COOKIES = ["AWSALB", "AWSALBCORS"]; +const FEED_EMPTY_RETRIES = 4; + /** * Main class of the FlightRadarAPI */ @@ -363,22 +370,25 @@ class FlightRadar24API { if (registration !== null) params["reg"] = registration; if (aircraftType !== null) params["type"] = aircraftType; - const { content } = await this.__client.request(Core.realTimeFlightTrackerDataUrl, { - params, - headers: Core.jsonHeaders, - timeout: this.timeout, - }); + let flights = []; - const flights = []; + for (let attempt = 0; attempt <= FEED_EMPTY_RETRIES; attempt++) { + const { content } = await this.__client.request(Core.realTimeFlightTrackerDataUrl, { + params, + headers: Core.jsonHeaders, + timeout: this.timeout, + }); - for (const flightId in content) { - if (!Object.prototype.hasOwnProperty.call(content, flightId)) { - continue; - } - if (!isNumeric(flightId[0])) { - continue; + // Get flights only. + flights = Object.entries(content ?? {}) + .filter(([flightId]) => isNumeric(flightId[0])) + .map(([flightId, info]) => new Flight(flightId, info)); + + // `full_count: 0` means the feed really has nothing to report. + if (flights.length > 0 || !(content?.["full_count"] > 0)) { + break; } - flights.push(new Flight(flightId, content[flightId])); + FEED_STICKY_COOKIES.forEach((name) => this.__client.deleteCookie(name)); } if (details) { diff --git a/nodejs/FlightRadarAPI/index.d.ts b/nodejs/FlightRadarAPI/index.d.ts index 42b6273..2be3c99 100644 --- a/nodejs/FlightRadarAPI/index.d.ts +++ b/nodejs/FlightRadarAPI/index.d.ts @@ -33,6 +33,8 @@ export class APIClient { requestStandalone(url: string, options?: object): Promise<{content: any; statusCode: number; cookies: Record}>; getCookie(name: string): string | undefined; clearCookies(): void; + /** Drop a single cookie, leaving the rest of the jar intact. */ + deleteCookie(name: string): void; } /** diff --git a/nodejs/FlightRadarAPI/request.js b/nodejs/FlightRadarAPI/request.js index 7cda54a..4e9c505 100644 --- a/nodejs/FlightRadarAPI/request.js +++ b/nodejs/FlightRadarAPI/request.js @@ -291,6 +291,18 @@ class Session { this.__cookies = {}; } + /** + * Drop a single stored cookie, leaving the rest of the jar intact. + * + * Sheds load-balancer stickiness without discarding the login session, + * which lives in the same jar. + * + * @param {string} name + */ + deleteCookie(name) { + delete this.__cookies[name]; + } + /** * Make an HTTP request, automatically sending stored cookies and storing * any cookies returned by the response. @@ -383,6 +395,15 @@ class APIClient { clearCookies() { this.__session.clearCookies(); } + + /** + * Drop a single cookie from the session, leaving the rest of the jar intact. + * + * @param {string} name + */ + deleteCookie(name) { + this.__session.deleteCookie(name); + } } module.exports = { request, Session, APIClient, RetryPolicy, buildImpersonateAgent, CHROME136_PROFILE }; diff --git a/nodejs/package.json b/nodejs/package.json index e9cd251..f61ab6d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -6,7 +6,7 @@ "types": "./FlightRadarAPI/index.d.ts", "scripts": { "test": "mocha tests --timeout 10000", - "test:offline": "mocha tests/testParsersOffline.js tests/testRequestPolicy.js tests/testRequestTransport.js --timeout 10000", + "test:offline": "mocha tests/testParsersOffline.js tests/testRequestPolicy.js tests/testRequestTransport.js tests/testFeedRetry.js --timeout 10000", "test:integration": "mocha tests/testApi.js tests/testSnapshots.js --timeout 10000", "test:types": "tsd", "lint": "eslint ." diff --git a/nodejs/tests/testFeedRetry.js b/nodejs/tests/testFeedRetry.js new file mode 100644 index 0000000..4e56e71 --- /dev/null +++ b/nodejs/tests/testFeedRetry.js @@ -0,0 +1,136 @@ +/** + * Offline tests for the degraded-live-feed recovery in getFlights(). + * + * A degraded FR24 feed backend answers 200 with a well-formed envelope but zero + * flight entries, and the `AWSALB` cookie pins the session to it. These tests + * pin down when getFlights() retries and when it must not, so the PR gate keeps + * guarding the behaviour while FR24 is healthy. + */ +const expect = require("chai").expect; + +const { FlightRadar24API } = require("../FlightRadarAPI/index"); + +// One real feed row, trimmed to the positional fields Flight actually reads. +const FLIGHT_ROW = [ + "ABC123", -23.43, -46.47, 90, 35000, 450, "1234", "", "B738", "PR-XYZ", + 1700000000, "GRU", "GIG", "G31234", 0, 0, "GLO1234", 0, "GLO", +]; + +const DEGRADED_FEED = { + "full_count": 22684, + "version": 4, + "stats": { total: { "ads-b": 18541 }, visible: { "ads-b": 0 } }, +}; + +const HEALTHY_FEED = { + "full_count": 24560, + "version": 4, + "3f6a31cd": FLIGHT_ROW, + "40ae422e": FLIGHT_ROW, +}; + +const IDLE_FEED = { "full_count": 0, "version": 4 }; + +/** + * Swap in a client double that replays `responses` in order and records which + * cookies getFlights() dropped between attempts. + * + * @param {Array} responses - feed payload per call; the last one repeats + * @return {{api: FlightRadar24API, calls: Array, deleted: Array}} + */ +function apiWithFeedResponses(responses) { + const api = new FlightRadar24API(); + const calls = []; + const deleted = []; + + api.__client = { + async request(url, options) { + calls.push({ url, options }); + const payload = responses[Math.min(calls.length - 1, responses.length - 1)]; + return { content: payload, statusCode: 200, cookies: {} }; + }, + deleteCookie(name) { + deleted.push(name); + }, + }; + return { api, calls, deleted }; +} + + +describe("getFlights() degraded-feed recovery (offline)", function() { + it("retries an empty feed and returns the flights from a healthy backend", async function() { + const { api, calls, deleted } = apiWithFeedResponses([DEGRADED_FEED, HEALTHY_FEED]); + + const flights = await api.getFlights(); + + expect(flights).to.have.lengthOf(2); + expect(calls).to.have.lengthOf(2); + // Without this the balancer routes the retry back to the same backend. + expect(deleted).to.include("AWSALB"); + expect(deleted).to.include("AWSALBCORS"); + }); + + it("keeps re-rolling while the feed stays empty, then gives up and returns []", async function() { + const { api, calls } = apiWithFeedResponses([DEGRADED_FEED]); + + const flights = await api.getFlights(); + + expect(flights).to.deep.equal([]); + // A permanently degraded upstream must not loop forever. + expect(calls.length).to.be.above(1); + expect(calls.length).to.be.at.most(6); + }); + + it("does not retry when the feed reports nothing to track (full_count 0)", async function() { + const { api, calls, deleted } = apiWithFeedResponses([IDLE_FEED]); + + const flights = await api.getFlights(); + + expect(flights).to.deep.equal([]); + expect(calls).to.have.lengthOf(1); + expect(deleted).to.deep.equal([]); + }); + + it("does not retry a healthy first response", async function() { + const { api, calls, deleted } = apiWithFeedResponses([HEALTHY_FEED]); + + const flights = await api.getFlights(); + + expect(flights).to.have.lengthOf(2); + expect(calls).to.have.lengthOf(1); + expect(deleted).to.deep.equal([]); + }); + + it("passes the caller's filters unchanged on every retry", async function() { + const { api, calls } = apiWithFeedResponses([DEGRADED_FEED, DEGRADED_FEED, HEALTHY_FEED]); + + await api.getFlights("GLO", "75,3,-180,-52"); + + expect(calls).to.have.lengthOf(3); + for (const call of calls) { + expect(call.options.params).to.include({ airline: "GLO", bounds: "75,3,-180,-52" }); + } + }); +}); + + +describe("Session.deleteCookie (offline)", function() { + it("drops one cookie and leaves the rest of the jar intact", function() { + const { Session } = require("../FlightRadarAPI/request"); + const session = new Session(); + + session.__cookies = { AWSALB: "sticky", _frPl: "login-token" }; + session.deleteCookie("AWSALB"); + + expect(session.getCookie("AWSALB")).to.equal(undefined); + // Shedding stickiness must not log the user out. + expect(session.getCookie("_frPl")).to.equal("login-token"); + }); + + it("is a no-op for a cookie that was never stored", function() { + const { Session } = require("../FlightRadarAPI/request"); + const session = new Session(); + + expect(() => session.deleteCookie("AWSALB")).to.not.throw(); + }); +}); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 41ab442..a097462 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -14,6 +14,13 @@ from .parsers import parse_airlines_html, parse_airports_html from .request import APIClient, RetryPolicy +# Some FR24 live-feed backends answer 200 with a well-formed envelope but no +# flight entries -- indistinguishable from a legitimately empty result. The +# "AWSALB" cookie then pins the session to that backend, so dropping it is what +# makes the load balancer re-roll on retry. +FEED_STICKY_COOKIES = ("AWSALB", "AWSALBCORS") +FEED_EMPTY_RETRIES = 4 + class FlightRadar24API: """ @@ -342,24 +349,31 @@ def get_flights( if registration is not None: request_params["reg"] = registration if aircraft_type is not None: request_params["type"] = aircraft_type - # Get all flights from Data Live FlightRadar24. - response = self.__client.request( - Core.real_time_flight_tracker_data_url, - params=request_params, - headers=Core.json_headers, - timeout=self.timeout, - ) - content = response.get_json_content() - flights: List[Flight] = list() - for flight_id, flight_info in content.items(): + for _ in range(FEED_EMPTY_RETRIES + 1): + # Get all flights from Data Live FlightRadar24. + response = self.__client.request( + Core.real_time_flight_tracker_data_url, + params=request_params, + headers=Core.json_headers, + timeout=self.timeout, + ) + content = response.get_json_content() # Get flights only. - if not flight_id[0].isnumeric(): - continue - - flights.append(Flight(flight_id, flight_info)) + flights = [ + Flight(flight_id, flight_info) + for flight_id, flight_info in content.items() + if flight_id[0].isnumeric() + ] + + # "full_count": 0 means the feed really has nothing to report. + if flights or not content.get("full_count"): + break + + for cookie_name in FEED_STICKY_COOKIES: + self.__client.delete_cookie(cookie_name) if details: with ThreadPoolExecutor(max_workers=self.max_workers) as executor: diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index 06fee97..9f2cff7 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -122,6 +122,17 @@ def clear_cookies(self) -> None: """Clear all cookies from the session.""" self.__session.cookies.clear() + def delete_cookie(self, name: str) -> None: + """Drop a single cookie, leaving the rest of the jar intact. + + Sheds load-balancer stickiness without discarding the login session, + which lives in the same jar. + """ + try: + del self.__session.cookies[name] + except KeyError: + pass + class APIRequest: """ diff --git a/python/tests/test_feed_retry.py b/python/tests/test_feed_retry.py new file mode 100644 index 0000000..41f22ff --- /dev/null +++ b/python/tests/test_feed_retry.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""Offline tests for the degraded-live-feed recovery in ``get_flights()``. + +A degraded FR24 feed backend answers 200 with a well-formed envelope but zero +flight entries, and the ``AWSALB`` cookie pins the session to it. These tests +pin down when ``get_flights()`` retries and when it must not, so the PR gate +keeps guarding the behaviour while FR24 is healthy. +""" + +from typing import Any, Dict, List + +from FlightRadarAPI import FlightRadar24API + +# One real feed row, trimmed to the positional fields Flight actually reads. +FLIGHT_ROW = [ + "ABC123", -23.43, -46.47, 90, 35000, 450, "1234", "", "B738", "PR-XYZ", + 1700000000, "GRU", "GIG", "G31234", 0, 0, "GLO1234", 0, "GLO", +] + +DEGRADED_FEED: Dict[str, Any] = { + "full_count": 22684, + "version": 4, + "stats": {"total": {"ads-b": 18541}, "visible": {"ads-b": 0}}, +} + +HEALTHY_FEED: Dict[str, Any] = { + "full_count": 24560, + "version": 4, + "3f6a31cd": FLIGHT_ROW, + "40ae422e": FLIGHT_ROW, +} + +IDLE_FEED: Dict[str, Any] = {"full_count": 0, "version": 4} + + +class _FakeResponse: + """Stand-in for ``APIRequest``; only the JSON body is ever read.""" + + def __init__(self, payload: Dict[str, Any]) -> None: + self._payload = payload + + def get_json_content(self) -> Dict[str, Any]: + return self._payload + + +class _FakeClient: + """Replays ``responses`` in order and records dropped cookies. + + The final response repeats once the list is exhausted, so a single-item + list models an upstream that never recovers. + """ + + def __init__(self, responses: List[Dict[str, Any]]) -> None: + self._responses = responses + self.calls: List[Dict[str, Any]] = [] + self.deleted: List[str] = [] + + def request(self, url: str, **kwargs: Any) -> _FakeResponse: + self.calls.append({"url": url, **kwargs}) + index = min(len(self.calls) - 1, len(self._responses) - 1) + return _FakeResponse(self._responses[index]) + + def delete_cookie(self, name: str) -> None: + self.deleted.append(name) + + +def _api_with_feed_responses(responses: List[Dict[str, Any]]): + api = FlightRadar24API() + client = _FakeClient(responses) + # Name-mangled private attribute -- set it the way Python stores it. + api._FlightRadar24API__client = client # type: ignore[attr-defined] + return api, client + + +class TestDegradedFeedRecovery: + def test_retries_empty_feed_and_returns_healthy_flights(self): + api, client = _api_with_feed_responses([DEGRADED_FEED, HEALTHY_FEED]) + + flights = api.get_flights() + + assert len(flights) == 2 + assert len(client.calls) == 2 + # Without this the balancer routes the retry back to the same backend. + assert "AWSALB" in client.deleted + assert "AWSALBCORS" in client.deleted + + def test_gives_up_and_returns_empty_when_feed_never_recovers(self): + api, client = _api_with_feed_responses([DEGRADED_FEED]) + + flights = api.get_flights() + + assert flights == [] + # A permanently degraded upstream must not loop forever. + assert 1 < len(client.calls) <= 6 + + def test_does_not_retry_when_nothing_is_tracked(self): + api, client = _api_with_feed_responses([IDLE_FEED]) + + flights = api.get_flights() + + assert flights == [] + assert len(client.calls) == 1 + assert client.deleted == [] + + def test_does_not_retry_healthy_first_response(self): + api, client = _api_with_feed_responses([HEALTHY_FEED]) + + flights = api.get_flights() + + assert len(flights) == 2 + assert len(client.calls) == 1 + assert client.deleted == [] + + def test_preserves_caller_filters_across_retries(self): + api, client = _api_with_feed_responses( + [DEGRADED_FEED, DEGRADED_FEED, HEALTHY_FEED] + ) + + api.get_flights("GLO", "75,3,-180,-52") + + assert len(client.calls) == 3 + for call in client.calls: + assert call["params"]["airline"] == "GLO" + assert call["params"]["bounds"] == "75,3,-180,-52" + + +class TestDeleteCookie: + def test_drops_one_cookie_and_keeps_the_rest(self): + from FlightRadarAPI.request import APIClient + + client = APIClient() + session = client._APIClient__session # type: ignore[attr-defined] + session.cookies.set("AWSALB", "sticky") + session.cookies.set("_frPl", "login-token") + + client.delete_cookie("AWSALB") + + assert client.get_cookie("AWSALB") is None + # Shedding stickiness must not log the user out. + assert client.get_cookie("_frPl") == "login-token" + + def test_is_a_no_op_for_an_absent_cookie(self): + from FlightRadarAPI.request import APIClient + + client = APIClient() + client.delete_cookie("AWSALB") # must not raise From 118a6b3ca2932aab9782d97cb7bbef564f77ec42 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 29 Jul 2026 10:36:52 -0300 Subject: [PATCH 2/3] chore: bump version to 1.5.2 --- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- python/FlightRadarAPI/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 1693d5f..66d4474 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "flightradarapi", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flightradarapi", - "version": "1.5.1", + "version": "1.5.2", "license": "MIT", "dependencies": { "node-html-parser": "^6.1.13", diff --git a/nodejs/package.json b/nodejs/package.json index f61ab6d..b4fb9a3 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "flightradarapi", - "version": "1.5.1", + "version": "1.5.2", "description": "SDK for FlightRadar24", "main": "./FlightRadarAPI/index.js", "types": "./FlightRadarAPI/index.d.ts", diff --git a/python/FlightRadarAPI/__init__.py b/python/FlightRadarAPI/__init__.py index 4c60769..e7c008a 100644 --- a/python/FlightRadarAPI/__init__.py +++ b/python/FlightRadarAPI/__init__.py @@ -12,7 +12,7 @@ """ __author__ = "Jean Loui Bernard Silva de Jesus" -__version__ = "1.5.1" +__version__ = "1.5.2" from .api import FlightRadar24API from .core import Countries From 55b80fb0cce27712c8483a6c6036c22c03554df6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 29 Jul 2026 10:47:57 -0300 Subject: [PATCH 3/3] ci: run live FR24 integration tests on pull requests --- .github/workflows/node-package.yml | 1 - .github/workflows/python-package.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index 13f5854..905a59b 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -41,7 +41,6 @@ jobs: - name: Offline tests (PR gate) run: npm run test:offline - name: Integration tests (live FR24) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' uses: nick-fields/retry@v3 with: timeout_minutes: 10 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ba89af3..54a6167 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -40,7 +40,6 @@ jobs: - name: Offline tests (PR gate) run: cd python && pytest -m "not integration" --cov=FlightRadarAPI --cov-report=term --cov-report=xml -v - name: Integration tests (live FR24) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' uses: nick-fields/retry@v3 with: timeout_minutes: 10