koala73/worldmonitor · error · BothSourcesFailedError

Airspace data unavailable: both civilian and military…

Error message

Airspace data unavailable: both civilian and military sources failed

What it means

BothSourcesFailedError (api/mcp/downstream.ts:324) thrown by get_airspace when the requested scope needs both feeds and neither side is usable: civilian read rejected (or returned a non-redistributable OpenSky provider payload, which counts as unusable) AND military read rejected. The message is fixed; the typed fields carry classifyFailureReason tags for each side (timeout / http_<status> / auth_error / error) plus full detail strings, so dispatch can distinguish a shared-host outage (same failure both sides) from two independent provider failures.

Solutions

  1. Read err.civilianFailure and err.militaryFailure — matching tags (e.g. both 'timeout') suggest a shared-network/outage cause; differing tags suggest independent provider issues
  2. Retry with backoff — provider outages are frequently transient; honor any provider quota reset for auth_error/quota codes
  3. As a workaround, call with type: 'military' or type: 'civilian' to degrade to the single working source (the tool returns warnings instead of throwing when one side is up)
  4. If civilianFailure is consistently provider-related (OpenSky attribution), the civilian source needs a redistributable provider — that is a data-policy fix, not a retry

Example fix

// before
const res = await client.callTool('get_airspace', { bbox: '55,10,60,20', type: 'both' });
// throws BothSourcesFailedError when both feeds are down

// after — degrade to whichever side is alive, surface the warning
try {
  const res = await client.callTool('get_airspace', { bbox: '55,10,60,20', type: 'both' });
} catch (e) {
  if (e?.name === 'BothSourcesFailedError') {
    const res = await client.callTool('get_airspace', { bbox: '55,10,60,20', type: 'military' });
    // res carries warnings: ['civilian ADS-B data unavailable']
  }
}
Defensive patterns

Strategy: retry

Type guard

function isBothSourcesFailed(e) {
  return typeof e === 'object' && e !== null
    && (e.name === 'BothSourcesFailedError'
      || (e.message === 'Airspace data unavailable: both civilian and military sources failed'
        && 'civilianFailure' in e && 'militaryFailure' in e));
}

Try / catch

try {
  return await client.callTool('get_airspace', { ...args, type: 'both' });
} catch (e) {
  if (isBothSourcesFailed(e)) {
    if (e.civilianFailure === e.militaryFailure) return retryWithBackoff(call, 3); // shared outage
    try {
      return await client.callTool('get_airspace', { ...args, type: 'military' }); // degrade one side
    } catch { return await client.callTool('get_airspace', { ...args, type: 'civilian' }); }
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling get_airspace with type 'both' (or default) while the civilian ADS-B fetch times out/fails AND the military flight data fetch also fails. Also triggered with type 'civilian' when the civilian source fails OR returns OpenSky-sourced data (redistribution not allowed — treated as unavailable) and military is not requested but... note: for type 'civilian' only civ matters, so this fires when civ fails AND the other required side (none) — in practice the both/military-independent double failure. With type 'military' a mil failure alone throws only if... it requires both required sides down.

Common situations: Shared upstream host outage taking down both ADS-B providers. Independent failures: OpenSky quota exhausted (civ) plus military feed timeout. Provider returned OpenSky-attributed data that policy forbids redistributing while military also failed. Network egress restrictions on the edge runtime.

Related errors


AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21). Data as JSON: /api/errors/8be75d37b12415bb. Report an issue: GitHub.

Appendix: source

Thrown at api/mcp/registry/rpc-tools.ts:2510

        }
        return parts.map(part => {
          if (part.status === 'rejected') throw part.reason;
          return part.value;
        });
      }

      const [civResult, milResult] = await Promise.allSettled([
        type === 'military' ? Promise.resolve(null) : fetchParts<TrackAircraftResponse>(
          queries.map(query => `${base}/api/aviation/v1/track-aircraft?${query}`), 'get-airspace-civilian'),
        type === 'civilian' ? Promise.resolve(null) : fetchParts<ListMilitaryFlightsResponse>(
          queries.map(query => `${base}/api/military/v1/list-military-flights?${query}&page_size=100`), 'get-airspace-military'),
      ]);

      // A billing denial is user-level, not a data-source outage: never serve
      // partial data or a generic both-failed error over it — rethrow so
      // dispatch re-emits the full billing contract (status, Retry-After,
      // X-Billing-Verification, data.code).
      for (const result of [civResult, milResult]) {
        if (result.status === 'rejected' && result.reason instanceof BillingDenialError) {
          throw result.reason;
        }
      }

      const civRaw = civResult.status === 'fulfilled' ? civResult.value : null;
      const civProviderAllowed = !civRaw || civRaw.every(part => !isOpenSkyProvider(part?.source));
      const civOk = type === 'military' || (civResult.status === 'fulfilled' && civProviderAllowed);
      const milOk = type === 'civilian' || milResult.status === 'fulfilled';

      // Both sources down — total outage, don't return misleading empty data
      if (!civOk && !milOk) {
        const civilianFailure = civResult.status === 'rejected'
          ? civResult.reason
          : new Error('Civilian observations are not redistributable');
        throw new BothSourcesFailedError(civilianFailure, milResult.reason);
      }

View on GitHub (pinned to e586b8b4b8)