Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/node-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 23 additions & 13 deletions nodejs/FlightRadarAPI/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions nodejs/FlightRadarAPI/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export class APIClient {
requestStandalone(url: string, options?: object): Promise<{content: any; statusCode: number; cookies: Record<string, string>}>;
getCookie(name: string): string | undefined;
clearCookies(): void;
/** Drop a single cookie, leaving the rest of the jar intact. */
deleteCookie(name: string): void;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions nodejs/FlightRadarAPI/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
4 changes: 2 additions & 2 deletions nodejs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions nodejs/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "flightradarapi",
"version": "1.5.1",
"version": "1.5.2",
"description": "SDK for FlightRadar24",
"main": "./FlightRadarAPI/index.js",
"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 ."
Expand Down
136 changes: 136 additions & 0 deletions nodejs/tests/testFeedRetry.js
Original file line number Diff line number Diff line change
@@ -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<object>} responses - feed payload per call; the last one repeats
* @return {{api: FlightRadar24API, calls: Array<object>, deleted: Array<string>}}
*/
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();
});
});
2 changes: 1 addition & 1 deletion python/FlightRadarAPI/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 28 additions & 14 deletions python/FlightRadarAPI/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions python/FlightRadarAPI/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
Loading
Loading