fix: report stale transit feeds and drop expired positions

get_transit_vehicles returned positions from a feed the proxy served from
its error cache, and hour-old fixes, as current. It now applies the transit
layer's rules: a feed is stale when the proxy says so or the operator has
not answered recently, and fixes older than the vehicle limit are dropped
and counted.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 61974848fe
commit 853c60583f
3 files changed
+96 -6

No files matched your search

+56 -4
View File
@@ -4,6 +4,10 @@ import {
parseStationInformation,
parseStationStatus,
} from '../../sources/gbfsStations.js';
import {
FEED_STALE_AFTER_MS,
isStaleVehicleFix,
} from '../../layers/transit/policy.js';
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
@@ -157,6 +161,7 @@ export const getTransitVehicles = defineTool({
data: { ...capRows([], args.limit), feeds: [] },
};
const center = areaCenter(area);
const now = services.clock?.now() ?? Date.now();
const results = await Promise.allSettled(
feeds.map(async (feed) => {
const response = await services.transit.requestSnapshot(feed.id, {
@@ -164,7 +169,31 @@ export const getTransitVehicles = defineTool({
});
if (!response.ok) throw new Error(`Transit HTTP ${response.status}`);
const snapshot = await response.json();
return (snapshot.vehicles || []).map((vehicle) => ({ feed, vehicle }));
// The app's freshness rules: the operator's fetch time ages the
// snapshot, and fixes older than the vehicle limit are not shown.
const fetchedAt = Number.isFinite(snapshot.fetchedAt)
? Math.min(snapshot.fetchedAt, now)
: now;
const contacted = Number.parseInt(
response.headers?.get?.('x-transit-contact') || '',
10,
);
const answeredAt = Math.max(
fetchedAt,
Number.isFinite(contacted) ? Math.min(contacted, now) : fetchedAt,
);
const stale =
response.headers?.get?.('x-gev-cache') === 'STALE-ERROR' ||
now - answeredAt > FEED_STALE_AFTER_MS;
const vehicles = snapshot.vehicles || [];
const current = vehicles.filter(
(vehicle) => !isStaleVehicleFix(vehicle, now, fetchedAt),
);
return {
stale,
expired: vehicles.length - current.length,
vehicles: current.map((vehicle) => ({ feed, vehicle })),
};
}),
);
signal?.throwIfAborted();
@@ -175,7 +204,9 @@ export const getTransitVehicles = defineTool({
);
const wanted = args.route?.trim().toLowerCase();
const rows = results
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
.flatMap((result) =>
result.status === 'fulfilled' ? result.value.vehicles : [],
)
.filter(({ vehicle }) => areaContains(area, vehicle))
.filter(
({ vehicle }) =>
@@ -205,15 +236,36 @@ export const getTransitVehicles = defineTool({
const failed = results.filter(
(result) => result.status === 'rejected',
).length;
const answered = results
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
const staleFeeds = answered.filter((feed) => feed.stale).length;
const expired = answered.reduce((total, feed) => total + feed.expired, 0);
const notes = [
...(failed ? [`${countNoun(failed, 'feed')} unavailable`] : []),
...(staleFeeds
? [
`${countNoun(staleFeeds, 'feed')} stale; positions may be out of date`,
]
: []),
];
const what = args.route ? ` on route ${args.route}` : '';
return {
summary:
`${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` +
(failed ? ` (${countNoun(failed, 'feed')} unavailable).` : '.'),
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
...capRows(rows, args.limit),
feeds: feeds.map((feed) => ({
stale: staleFeeds > 0,
expired_positions_dropped: expired,
feeds: feeds.map((feed, index) => ({
name: feed.name,
status:
results[index].status === 'rejected'
? 'unavailable'
: results[index].value.stale
? 'stale'
: 'current',
operator: feed.operator ?? null,
attribution: feed.attribution ?? null,
license: feed.license ?? null,
+35 -1
View File
@@ -177,6 +177,8 @@ const vehicles = [
},
{ id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' },
];
// One minute after v1's fix.
const clock = { now: () => Date.UTC(2026, 0, 1, 0, 1) };
const transit = (
snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) },
) => ({
@@ -197,7 +199,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
const source = transit();
const catalog = composeCatalog({
tools: coreTools,
services: { transit: source },
services: { transit: source, clock },
});
const all = await catalog.call('get_transit_vehicles', { area: downtown });
assert.equal(
@@ -223,6 +225,7 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
assert.deepEqual(all.data.feeds, [
{
name: 'CapMetro',
status: 'current',
operator: 'Capital Metro',
attribution: 'Capital Metro',
license: 'Open data',
@@ -370,3 +373,34 @@ test('traffic reports a missing key, empty areas and areas that are too wide', a
/too large for traffic detail/,
);
});
test('transit marks stale feeds and drops expired positions', async () => {
const headers = new Headers({ 'x-gev-cache': 'STALE-ERROR' });
const snapshot = (extra) => ({
ok: true,
status: 200,
headers,
json: async () => ({
vehicles: [
vehicles[0],
{ ...vehicles[0], id: 'old', timestamp: 1767225600 - 3600 },
],
...extra,
}),
});
const result = await composeCatalog({
tools: coreTools,
services: { transit: transit(snapshot()), clock },
}).call('get_transit_vehicles', { area: downtown });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['v1'],
);
assert.equal(result.data.expired_positions_dropped, 1);
assert.equal(result.data.stale, true);
assert.equal(result.data.feeds[0].status, 'stale');
assert.equal(
result.summary,
'1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).',
);
});