fix(bikeshare): let one dead city feed fail without failing the layer

The layer recorded failure in a single `_error` string. Every city feed is
fetched independently, so one retired operator set that flag for the whole
layer — and the only code that cleared it ran on a successful *activation*,
which `runProximityCheck` skips for a city that already has rendered points.
A dead feed that re-entered camera range therefore set the flag on every
camera move and nothing ever cleared it: a permanent LOAD FAILED over a map
drawing every other city's stations.

Failure is now tracked per city in `_failedCityIds`, and `getStats()` reports
it only when the failures left nothing to draw. A city that recovers removes
its own entry rather than waiting for a neighbour to succeed.

This was reachable only because Buffalo sits 95 km from Toronto, inside the
100 km activation radius — the one dead city in the registry with a healthy
neighbour in range. The other four failed alone, where LOAD FAILED with an
empty map reads as correct, which is why the latch went unnoticed.

Probing all 33 registry systems found five dead. Three had moved and are
repointed: Reddy Bikeshare (Buffalo) to Social Bicycles, Tugo (Tucson) and
Trinity Metro (Fort Worth) to PBSC, all verified serving stations. Two are
dropped because the systems wound down rather than moved — Broward B-cycle
404s at every documented endpoint, and CoGo's replacement Lyft feed publishes
zero stations with status last reported in March 2025. `gbfs.cogobikeshare.com`
no longer resolves, so its allowlist entry goes too.

All 31 remaining systems were verified live, and every registry URL was
checked against the proxy host and path allowlists.
This commit is contained in:
Cliff Flood committed 2026-10-13 18:55:20 -04:00
1 parent b8ccfd1648
commit 001043f74e
10 files changed
+144 -38

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog
- Stop one retired bikeshare feed from reporting the whole layer as failed.
City feeds are fetched independently, but failure was recorded in a single
flag that only a successful *activation* cleared — and an already-loaded city
never activates again. A dead feed re-entering camera range therefore latched
LOAD FAILED permanently over a map still drawing every other city's stations.
Failure is now tracked per city and reported only when it leaves the layer
with nothing to draw; a city that recovers clears its own failure.
Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo)
to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC.
Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems
have wound down — CoGo's feed reports no stations and last moved a bike in
March 2025. All 31 remaining systems were verified live.
- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system
publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga
entries, so it needs no new plumbing; a registry test now holds every city's
+1 -1
View File
@@ -4,7 +4,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
'gbfs.bluebikes.com',
'gbfs.bcycle.com',
'gbfs.biketownpdx.com',
'gbfs.cogobikeshare.com',
'reddybikeshare.socialbicycles.com',
'austin.publicbikesystem.net',
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
+8 -1
View File
@@ -70,7 +70,14 @@ export function createControls({ state: layerState, services, parts, source }) {
? `syncing ${layerState._activeCityIds.size} city feeds...`
: 'scanning nearby systems...';
}
if (layerState._error) stats.error = layerState._error;
// A failed city is only the layer's failure when it left nothing on the
// map. While any city still renders, the layer is working and says so.
if (
layerState._failedCityIds.size > 0 &&
layerState._stationRenderMap.size === 0
) {
stats.error = 'GBFS fetch error';
}
return stats;
},
};
@@ -0,0 +1,80 @@
// BIKESHARE FAILURE REPORTING — one dead city is not a dead layer.
//
// City feeds are fetched independently, but the layer reported failure through
// a single flag. A city whose operator had retired its feed set that flag on
// every proximity check, and the only code that cleared it ran on a successful
// *activation* — which an already-loaded city never repeats. The result was a
// permanent LOAD FAILED over a map drawing a thousand live stations.
//
// These cases pin the rule that replaced it: the layer reports failure only
// when it has nothing to show, and a city that comes back clears its own
// failure rather than waiting for a neighbour to succeed.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createControls } from './controls.js';
function makeLayer() {
const services = {
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
};
const state = createState({ services });
const parts = { queries: { collectDetectableStations: () => [] } };
const { methods } = createControls({ state, services, parts, source: {} });
return { state, methods };
}
/** Stand in for a rendered station without reaching into the renderer. */
function renderStations(state, count) {
for (let i = 0; i < count; i += 1) state._stationRenderMap.set(`s${i}`, {});
state._count = state._stationRenderMap.size;
}
test('a failed city does not fail a layer that is drawing stations', () => {
const { state, methods } = makeLayer();
renderStations(state, 1069);
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 1069);
assert.equal(
stats.error,
undefined,
'a layer drawing 1069 stations reported LOAD FAILED',
);
});
test('the layer reports failure when a failed city left it with nothing', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
const stats = methods.getStats();
assert.equal(stats.count, 0);
assert.equal(stats.error, 'GBFS fetch error');
});
test('a layer with nothing to draw and nothing failed is not an error', () => {
const { state, methods } = makeLayer();
// Out of range of every city is empty, not broken.
assert.equal(methods.getStats().error, undefined);
});
test('a recovered city clears its own failure', () => {
const { state, methods } = makeLayer();
state._failedCityIds.add('buffalo-reddy');
assert.equal(methods.getStats().error, 'GBFS fetch error');
state._failedCityIds.delete('buffalo-reddy');
assert.equal(
methods.getStats().error,
undefined,
'the failure outlived the city that caused it',
);
});
+2 -1
View File
@@ -182,13 +182,14 @@ export function createIngestion({
)
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._failedCityIds.delete(cityId);
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(
`[Data:Bikeshare] ${cityId} status update error:`,
error,
);
layerState._error = 'GBFS status update failed';
layerState._failedCityIds.add(cityId);
}
}),
);
+2 -2
View File
@@ -51,7 +51,7 @@ export function createLifecycle({
layerState._lastUpdate = null;
layerState._loading = false;
layerState._loadingOps = 0;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._limitWarned = false;
layerState._overlayHost.setVisible(
@@ -75,7 +75,7 @@ export function createLifecycle({
*/
enable(viewer) {
layerState._enabled = true;
layerState._error = null;
layerState._failedCityIds.clear();
layerState._pointCollection.show = true;
layerState._overlayHost.setVisible(
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
+25 -29
View File
@@ -143,18 +143,6 @@ const RAW_GBFS_CITY_REGISTRY = [
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Biki',
},
{
id: 'columbus-cogo',
city: 'Columbus, OH',
centerLat: 39.9612,
centerLon: -82.9988,
loadRadiusKm: 90,
stationInformationUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
stationStatusUrl:
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
provider: 'CoGo',
},
{
id: 'chattanooga-bikechatt',
city: 'Chattanooga, TN',
@@ -246,14 +234,6 @@ const RAW_GBFS_CITY_REGISTRY = [
systemId: 'bcycle_pacersbikeshare',
provider: 'Pacers Bikeshare',
}),
bcycleEntry({
id: 'fort-lauderdale-broward',
city: 'Fort Lauderdale, FL',
centerLat: 26.1224,
centerLon: -80.1373,
systemId: 'bcycle_broward',
provider: 'Broward B-cycle',
}),
bcycleEntry({
id: 'memphis-bcycle',
city: 'Memphis, TN',
@@ -268,22 +248,32 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -93.625,
systemId: 'bcycle_desmoines',
}),
bcycleEntry({
{
// Tugo moved off BCycle to PBSC; the bcycle_tugo feed is gone (404).
id: 'tucson-tugo',
city: 'Tucson, AZ',
centerLat: 32.2226,
centerLon: -110.9747,
systemId: 'bcycle_tugo',
loadRadiusKm: 100,
stationInformationUrl:
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Tugo',
}),
bcycleEntry({
},
{
// Trinity Metro moved off BCycle to PBSC; the bcycle_fortworth feed is gone.
id: 'fort-worth-trinity',
city: 'Fort Worth, TX',
centerLat: 32.7555,
centerLon: -97.3308,
systemId: 'bcycle_fortworth',
loadRadiusKm: 100,
stationInformationUrl:
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
stationStatusUrl:
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
provider: 'Trinity Metro',
}),
},
bcycleEntry({
id: 'omaha-heartland',
city: 'Omaha, NE',
@@ -307,14 +297,20 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -82.394,
systemId: 'bcycle_greenville',
}),
bcycleEntry({
{
// Reddy left BCycle for Social Bicycles; the bcycle_reddy feed is gone
// (404). This is the endpoint the MobilityData GBFS registry lists.
id: 'buffalo-reddy',
city: 'Buffalo, NY',
centerLat: 42.8864,
centerLon: -78.8784,
systemId: 'bcycle_reddy',
loadRadiusKm: 100,
stationInformationUrl:
'https://reddybikeshare.socialbicycles.com/opendata/station_information.json',
stationStatusUrl:
'https://reddybikeshare.socialbicycles.com/opendata/station_status.json',
provider: 'Reddy Bikeshare',
}),
},
bcycleEntry({
id: 'las-vegas-rtc-bike-share',
city: 'Las Vegas, NV',
+9 -2
View File
@@ -99,9 +99,16 @@ export function createState({ services }) {
layerState._loadingOps = 0;
/** Most recent error message string, or null. */
/**
* @type {Set<string>} City ids whose most recent fetch failed.
*
* Per city rather than one layer-wide flag: feeds are fetched independently,
* so a retired operator must not mark a layer that other cities are still
* populating as failed. The layer reports failure only when these cost it
* everything it had to draw — see `getStats()` in controls.js.
*/
layerState._error = null;
layerState._failedCityIds = new Set();
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
+2 -2
View File
@@ -168,11 +168,11 @@ export function createViewport({ state: layerState, services, parts, source }) {
return;
parts.rendering.applyStatusToPoints(cityId, statusMap);
layerState._lastUpdate = Date.now();
layerState._error = null;
layerState._failedCityIds.delete(cityId);
} catch (error) {
if (error?.name === 'AbortError') return;
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
layerState._error = 'GBFS fetch error';
layerState._failedCityIds.add(cityId);
deactivateCity(cityId);
layerState._activeCityIds.delete(cityId);
} finally {
+1
View File
@@ -3586,6 +3586,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
_pointCollection: {},
_overlayHost: { setVisible: noop },
_cityRuntime: new Map(),
_failedCityIds: new Set(),
},
services: {
sprites: { restoreSpriteOrder: noop },