fix: report adsb unavailable state as degraded
This commit is contained in:
@@ -59,7 +59,8 @@ export async function runSource(name, fn, ...args) {
|
|||||||
});
|
});
|
||||||
const data = await Promise.race([dataPromise, timeoutPromise]);
|
const data = await Promise.race([dataPromise, timeoutPromise]);
|
||||||
const hasError = Boolean(data?.error);
|
const hasError = Boolean(data?.error);
|
||||||
const isDegraded = hasError || ['no_credentials', 'degraded', 'failed'].includes(data?.status);
|
const degradedStatuses = ['no_credentials', 'no_key', 'disabled', 'degraded', 'failed', 'error'];
|
||||||
|
const isDegraded = hasError || degradedStatuses.includes(data?.status);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
status: isDegraded ? 'degraded' : 'ok',
|
status: isDegraded ? 'degraded' : 'ok',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
// ADS-B Exchange — Unfiltered Flight Tracking (including Military)
|
||||||
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
// Unlike FlightRadar24/FlightAware, ADS-B Exchange does NOT filter military aircraft.
|
||||||
// Public feed access varies; RapidAPI tier available for programmatic use.
|
// Public feed access varies; RapidAPI tier available for programmatic use.
|
||||||
// This module attempts the public endpoints and falls back to a documented stub.
|
// This module reports explicit disabled/degraded state instead of making
|
||||||
|
// unavailable aircraft data look live.
|
||||||
|
|
||||||
import { safeFetch } from '../utils/fetch.mjs';
|
import { safeFetch } from '../utils/fetch.mjs';
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
// Get all military aircraft
|
// Get all military aircraft
|
||||||
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
const data = await safeFetch(`${ENDPOINTS.rapidApi}/mil`, {
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
|
source: 'adsb-rapidapi',
|
||||||
headers: {
|
headers: {
|
||||||
'X-RapidAPI-Key': apiKey,
|
'X-RapidAPI-Key': apiKey,
|
||||||
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
'X-RapidAPI-Host': 'adsbexchange-com1.p.rapidapi.com',
|
||||||
@@ -151,21 +153,26 @@ async function fetchViaRapidApi(apiKey) {
|
|||||||
|
|
||||||
// Attempt to fetch from public feed
|
// Attempt to fetch from public feed
|
||||||
async function fetchPublicFeed() {
|
async function fetchPublicFeed() {
|
||||||
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000 });
|
const data = await safeFetch(ENDPOINTS.publicFeed, { timeout: 15000, source: 'adsb-public' });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get military aircraft from available sources
|
async function getMilitaryAircraftResult(apiKey) {
|
||||||
export async function getMilitaryAircraft(apiKey) {
|
const failures = [];
|
||||||
|
|
||||||
// Try RapidAPI first if key available
|
// Try RapidAPI first if key available
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const data = await fetchViaRapidApi(apiKey);
|
const data = await fetchViaRapidApi(apiKey);
|
||||||
if (data && !data.error) {
|
if (data && !data.error) {
|
||||||
const aircraft = data.ac || data.aircraft || [];
|
const aircraft = data.ac || data.aircraft || [];
|
||||||
if (Array.isArray(aircraft)) {
|
if (Array.isArray(aircraft)) {
|
||||||
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
|
return {
|
||||||
|
provider: 'rapidapi',
|
||||||
|
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
failures.push({ provider: 'rapidapi', error: data?.error || 'RapidAPI returned an unsupported payload' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try public feed
|
// Try public feed
|
||||||
@@ -173,11 +180,21 @@ export async function getMilitaryAircraft(apiKey) {
|
|||||||
if (pubData && !pubData.error) {
|
if (pubData && !pubData.error) {
|
||||||
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
const aircraft = pubData.ac || pubData.aircraft || pubData.states || [];
|
||||||
if (Array.isArray(aircraft)) {
|
if (Array.isArray(aircraft)) {
|
||||||
return aircraft.map(classifyAircraft).filter(a => a.isMilitary);
|
return {
|
||||||
|
provider: 'public-feed',
|
||||||
|
aircraft: aircraft.map(classifyAircraft).filter(a => a.isMilitary),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
failures.push({ provider: 'public-feed', error: pubData?.error || 'Public feed returned an unsupported payload' });
|
||||||
|
|
||||||
return null; // all sources failed
|
return { provider: null, aircraft: null, failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get military aircraft from available sources
|
||||||
|
export async function getMilitaryAircraft(apiKey) {
|
||||||
|
const result = await getMilitaryAircraftResult(apiKey);
|
||||||
|
return result.aircraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all aircraft in a geographic bounding box via RapidAPI
|
// Get all aircraft in a geographic bounding box via RapidAPI
|
||||||
@@ -208,7 +225,8 @@ export async function getAircraftInArea(lat, lon, radiusNm = 250, apiKey) {
|
|||||||
// Briefing — attempt to get military flight data, document what's available
|
// Briefing — attempt to get military flight data, document what's available
|
||||||
export async function briefing() {
|
export async function briefing() {
|
||||||
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
|
const apiKey = process.env.ADSB_API_KEY || process.env.RAPIDAPI_KEY || null;
|
||||||
const militaryAircraft = await getMilitaryAircraft(apiKey);
|
const result = await getMilitaryAircraftResult(apiKey);
|
||||||
|
const militaryAircraft = result.aircraft;
|
||||||
|
|
||||||
// If we got data, analyze it
|
// If we got data, analyze it
|
||||||
if (militaryAircraft && militaryAircraft.length > 0) {
|
if (militaryAircraft && militaryAircraft.length > 0) {
|
||||||
@@ -255,6 +273,7 @@ export async function briefing() {
|
|||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: 'live',
|
status: 'live',
|
||||||
|
provider: result.provider,
|
||||||
totalMilitary: militaryAircraft.length,
|
totalMilitary: militaryAircraft.length,
|
||||||
byCountry,
|
byCountry,
|
||||||
categories: {
|
categories: {
|
||||||
@@ -269,10 +288,18 @@ export async function briefing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No data available — return stub with integration documentation
|
// No data available — return stub with integration documentation
|
||||||
|
const status = apiKey ? 'degraded' : 'disabled';
|
||||||
|
const error = apiKey
|
||||||
|
? 'ADS-B providers returned no usable aircraft data'
|
||||||
|
: 'ADSB_API_KEY or RAPIDAPI_KEY is not configured';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'ADS-B Exchange',
|
source: 'ADS-B Exchange',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: apiKey ? 'error' : 'no_key',
|
status,
|
||||||
|
provider: result.provider,
|
||||||
|
error,
|
||||||
|
failures: result.failures,
|
||||||
militaryAircraft: [],
|
militaryAircraft: [],
|
||||||
message: apiKey
|
message: apiKey
|
||||||
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
? 'ADS-B Exchange API returned no data. The endpoint may be temporarily unavailable.'
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ Source docs:
|
|||||||
- [Telegram](telegram.md)
|
- [Telegram](telegram.md)
|
||||||
- [FIRMS](firms.md)
|
- [FIRMS](firms.md)
|
||||||
- [Maritime](maritime.md)
|
- [Maritime](maritime.md)
|
||||||
|
- [ADS-B](adsb.md)
|
||||||
|
|||||||
24
docs/sources/adsb.md
Normal file
24
docs/sources/adsb.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ADS-B Source
|
||||||
|
|
||||||
|
ADS-B Exchange support is optional and intended for unfiltered aircraft and military-flight awareness.
|
||||||
|
|
||||||
|
- Source module: `apis/sources/adsb.mjs`
|
||||||
|
- Preferred provider: ADS-B Exchange via RapidAPI
|
||||||
|
- Credentials: `ADSB_API_KEY` or `RAPIDAPI_KEY`
|
||||||
|
- Runtime status without credentials: `disabled`
|
||||||
|
- Runtime status when providers fail: `degraded`
|
||||||
|
- Runtime status with usable aircraft payloads: `live`
|
||||||
|
|
||||||
|
The source does not treat a missing key or unavailable public feed as normal live data. `/api/health` and `/api/metrics` surface the degraded source state through the sweep source summary.
|
||||||
|
|
||||||
|
Known failure modes:
|
||||||
|
|
||||||
|
- Missing `ADSB_API_KEY` / `RAPIDAPI_KEY`: source is disabled with operator guidance.
|
||||||
|
- RapidAPI rejects or rate-limits the request: source is degraded and records provider failure detail.
|
||||||
|
- Public feed is blocked, rate-limited, or changes shape: source remains degraded instead of returning stale-looking data.
|
||||||
|
|
||||||
|
Register for the provider documented in the README, then set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADSB_API_KEY=<rapidapi-key>
|
||||||
|
```
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"brief:save": "node apis/save-briefing.mjs",
|
"brief:save": "node apis/save-briefing.mjs",
|
||||||
"diag": "node diag.mjs",
|
"diag": "node diag.mjs",
|
||||||
"test": "npm run test:unit",
|
"test": "npm run test:unit",
|
||||||
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs",
|
"test:unit": "node --test test/llm-openrouter.test.mjs test/llm-ollama.test.mjs test/llm-openai-compatible.test.mjs test/fetch-utils.test.mjs test/adsb.test.mjs",
|
||||||
"compose:config": "docker compose config",
|
"compose:config": "docker compose config",
|
||||||
"clean": "node scripts/clean.mjs",
|
"clean": "node scripts/clean.mjs",
|
||||||
"fresh-start": "npm run clean && npm start"
|
"fresh-start": "npm run clean && npm start"
|
||||||
|
|||||||
82
test/adsb.test.mjs
Normal file
82
test/adsb.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
async function withFetch(mockFetch, fn) {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalAdsbKey = process.env.ADSB_API_KEY;
|
||||||
|
const originalRapidKey = process.env.RAPIDAPI_KEY;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
delete process.env.ADSB_API_KEY;
|
||||||
|
delete process.env.RAPIDAPI_KEY;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalAdsbKey === undefined) delete process.env.ADSB_API_KEY;
|
||||||
|
else process.env.ADSB_API_KEY = originalAdsbKey;
|
||||||
|
if (originalRapidKey === undefined) delete process.env.RAPIDAPI_KEY;
|
||||||
|
else process.env.RAPIDAPI_KEY = originalRapidKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(payload, ok = true, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: { get: () => 'application/json' },
|
||||||
|
text: async () => JSON.stringify(payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ADS-B reports disabled when no key is configured and public feed fails', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({ error: 'blocked' }, false, 403), async () => {
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'disabled');
|
||||||
|
assert.match(data.error, /ADSB_API_KEY|RAPIDAPI_KEY/);
|
||||||
|
assert.equal(data.militaryAircraft.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ADS-B reports degraded when RapidAPI and public feed fail', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({ error: 'unavailable' }, false, 503), async () => {
|
||||||
|
process.env.ADSB_API_KEY = 'test-key';
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'degraded');
|
||||||
|
assert.match(data.error, /providers returned no usable/);
|
||||||
|
assert.equal(data.failures.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ADS-B returns live RapidAPI military aircraft payloads', async () => {
|
||||||
|
await withFetch(async () => jsonResponse({
|
||||||
|
ac: [{
|
||||||
|
hex: 'AE1234',
|
||||||
|
flight: 'RCH123',
|
||||||
|
t: 'KC135',
|
||||||
|
lat: 50,
|
||||||
|
lon: 8,
|
||||||
|
mil: true,
|
||||||
|
}],
|
||||||
|
}), async () => {
|
||||||
|
process.env.ADSB_API_KEY = 'test-key';
|
||||||
|
const { briefing } = await import('../apis/sources/adsb.mjs');
|
||||||
|
const data = await briefing();
|
||||||
|
|
||||||
|
assert.equal(data.status, 'live');
|
||||||
|
assert.equal(data.provider, 'rapidapi');
|
||||||
|
assert.equal(data.totalMilitary, 1);
|
||||||
|
assert.equal(data.militaryAircraft[0].callsign, 'RCH123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSource treats disabled source status as degraded health', async () => {
|
||||||
|
const { runSource } = await import('../apis/briefing.mjs');
|
||||||
|
const result = await runSource('ADS-B', async () => ({ status: 'disabled', message: 'missing key' }));
|
||||||
|
|
||||||
|
assert.equal(result.status, 'degraded');
|
||||||
|
assert.equal(result.error, null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user