fix: report adsb unavailable state as degraded
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 1m13s

This commit is contained in:
MrSphay
2026-05-17 13:55:42 +02:00
parent c2d572e6f5
commit b2f604b120
6 changed files with 146 additions and 11 deletions

View File

@@ -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',

View File

@@ -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.'

View File

@@ -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
View 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>
```

View File

@@ -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
View 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);
});