Skip to content

Commit 9753a6a

Browse files
authored
fix: harden frontend against API errors and rate limiting (#265)
- Add type guards to validate API responses are arrays before use - Throw errors on HTTP failures to enable react-query retry (3x with exponential backoff) - Preserve previous valid data when refetches fail (useState only updates on success) - Make prerender.ts handle missing/empty station data gracefully - Add validation for prediction responses
1 parent 7f37ea2 commit 9753a6a

File tree

3 files changed

+107
-59
lines changed

3 files changed

+107
-59
lines changed

src/hooks/useMbtaApi.ts

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,23 @@ export interface MBTAApiReady {
2424

2525
export type MBTAApiResponse = MBTAApi | MBTAApiReady;
2626

27+
// Helper to validate array responses from API
28+
const isValidArray = <T>(data: unknown): data is T[] => {
29+
return Array.isArray(data);
30+
};
31+
2732
// if isFirstRequest is true, get train positions from intial request data JSON
2833
// if isFirstRequest is false, makes request for new train positions through backend server via chalice route defined in app.py
29-
const getTrainPositions = (routes: string[]): Promise<Train[]> => {
30-
return fetch(`${APP_DATA_BASE_PATH}/trains/${routes.join(',')}`).then((res) => res.json());
34+
const getTrainPositions = async (routes: string[]): Promise<Train[]> => {
35+
const res = await fetch(`${APP_DATA_BASE_PATH}/trains/${routes.join(',')}`);
36+
if (!res.ok) {
37+
throw new Error(`Failed to fetch trains: ${res.status} ${res.statusText}`);
38+
}
39+
const data = await res.json();
40+
if (!isValidArray<Train>(data)) {
41+
throw new Error(`Invalid train data received: ${JSON.stringify(data).slice(0, 100)}`);
42+
}
43+
return data;
3144
};
3245

3346
const filterNew = (trains: Train[]) => {
@@ -59,12 +72,28 @@ const filterTrains = (trains: Train[], vehiclesAge: VehicleCategory) => {
5972
return trains;
6073
};
6174

62-
const getStationsForRoute = (route: string) => {
63-
return fetch(`${APP_DATA_BASE_PATH}/stops/${route}`).then((res) => res.json());
75+
const getStationsForRoute = async (route: string): Promise<Station[]> => {
76+
const res = await fetch(`${APP_DATA_BASE_PATH}/stops/${route}`);
77+
if (!res.ok) {
78+
throw new Error(`Failed to fetch stations for ${route}: ${res.status} ${res.statusText}`);
79+
}
80+
const data = await res.json();
81+
if (!isValidArray<Station>(data)) {
82+
throw new Error(`Invalid station data for ${route}: ${JSON.stringify(data).slice(0, 100)}`);
83+
}
84+
return data;
6485
};
6586

66-
const getRoutesInfo = (routes: string[]) => {
67-
return fetch(`${APP_DATA_BASE_PATH}/routes/${routes.join(',')}`).then((res) => res.json());
87+
const getRoutesInfo = async (routes: string[]): Promise<Route[]> => {
88+
const res = await fetch(`${APP_DATA_BASE_PATH}/routes/${routes.join(',')}`);
89+
if (!res.ok) {
90+
throw new Error(`Failed to fetch routes info: ${res.status} ${res.statusText}`);
91+
}
92+
const data = await res.json();
93+
if (!isValidArray<Route>(data)) {
94+
throw new Error(`Invalid routes info received: ${JSON.stringify(data).slice(0, 100)}`);
95+
}
96+
return data;
6897
};
6998

7099
export const useMbtaApi = (
@@ -88,6 +117,8 @@ export const useMbtaApi = (
88117
enabled: !!routeNames,
89118
staleTime: FIFTEEN_SECONDS,
90119
refetchInterval: FIFTEEN_SECONDS,
120+
retry: 3,
121+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
91122
});
92123

93124
const trainsByRoute = useMemo(() => {
@@ -108,40 +139,43 @@ export const useMbtaApi = (
108139

109140
useQuery({
110141
queryKey: ['getStations', routeNamesKey],
111-
queryFn: () => {
142+
queryFn: async () => {
112143
const nextStopsByRoute: Record<string, Station[]> = {};
113-
return Promise.all(
114-
routeNames.map((routeName) =>
115-
getStationsForRoute(routeName).then((data) => {
116-
nextStopsByRoute[routeName] = data;
117-
})
118-
)
119-
).then(() => {
120-
setStationsByRoute(nextStopsByRoute);
121-
return nextStopsByRoute;
122-
});
144+
await Promise.all(
145+
routeNames.map(async (routeName) => {
146+
const data = await getStationsForRoute(routeName);
147+
nextStopsByRoute[routeName] = data;
148+
})
149+
);
150+
// Only update state on successful fetch
151+
setStationsByRoute(nextStopsByRoute);
152+
return nextStopsByRoute;
123153
},
124154
// if routeNames is empty, don't make the request
125155
enabled: !!routeNames && routeNames.length > 0,
126156
staleTime: ONE_DAY,
157+
retry: 3,
158+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
127159
});
128160

129161
useQuery({
130162
queryKey: ['getRoutesInfo', routeNamesKey],
131-
queryFn: () => {
163+
queryFn: async () => {
132164
const nextRoutesInfo: Record<string, Route> = {};
133-
return getRoutesInfo(routeNames).then((routes: Route[]) => {
134-
routes.forEach((route: Route) => {
135-
if (route.id) {
136-
nextRoutesInfo[route.id] = route;
137-
}
138-
});
139-
setRoutesInfoByRoute(nextRoutesInfo);
140-
return nextRoutesInfo;
165+
const routes = await getRoutesInfo(routeNames);
166+
routes.forEach((route: Route) => {
167+
if (route.id) {
168+
nextRoutesInfo[route.id] = route;
169+
}
141170
});
171+
// Only update state on successful fetch
172+
setRoutesInfoByRoute(nextRoutesInfo);
173+
return nextRoutesInfo;
142174
},
143175
enabled: !!routeNames && routeNames.length > 0,
144176
staleTime: ONE_DAY,
177+
retry: 3,
178+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
145179
});
146180

147181
const isReady =

src/hooks/usePrediction.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,40 @@
11
import { APP_DATA_BASE_PATH, FIFTEEN_SECONDS } from '../constants';
2+
import { Prediction } from '../types';
23
import { useQuery } from '@tanstack/react-query';
34

4-
const getPrediction = (tripId: string | null, stopId: string) => {
5+
const isValidPrediction = (data: unknown): data is Prediction => {
6+
return (
7+
data !== null &&
8+
typeof data === 'object' &&
9+
'departure_time' in data &&
10+
data.departure_time !== 'null'
11+
);
12+
};
13+
14+
const getPrediction = async (tripId: string | null, stopId: string): Promise<Prediction | null> => {
515
if (!tripId) {
6-
return Promise.resolve(null);
16+
return null;
717
}
818

9-
return fetch(`${APP_DATA_BASE_PATH}/predictions/${tripId}/${stopId}`).then((res) => {
10-
return res.json();
11-
});
19+
const res = await fetch(`${APP_DATA_BASE_PATH}/predictions/${tripId}/${stopId}`);
20+
if (!res.ok) {
21+
return null;
22+
}
23+
const data = await res.json();
24+
if (!isValidPrediction(data)) {
25+
// Prediction may legitimately not exist, so just return null without error
26+
return null;
27+
}
28+
return data;
1229
};
1330

14-
export const usePrediction = (tripId: string | null, stopId: string) => {
31+
export const usePrediction = (tripId: string | null, stopId: string): Prediction | null => {
1532
const { data: prediction } = useQuery({
1633
queryKey: ['getPrediction', tripId, stopId],
1734
queryFn: () => getPrediction(tripId, stopId),
1835
enabled: !!tripId,
1936
staleTime: FIFTEEN_SECONDS,
2037
});
2138

22-
return prediction;
39+
return prediction ?? null;
2340
};

src/prerender.ts

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,21 @@ const getStationIdsWithinRange = (stationRange: Shape, stationIds: string[] | un
2020
return stations;
2121
}
2222
if (!start || !end) {
23-
throw new Error(
24-
'Improper use of {start, end} properties for stationRange. ' +
25-
'These properties are required if stations property is not defined.'
26-
);
23+
return [];
2724
}
2825

29-
const startIndex = stationIds?.indexOf(start);
30-
const endIndex = stationIds?.indexOf(end);
31-
if (startIndex === -1 || endIndex === -1) {
32-
throw new Error(
33-
`Improper use of {start=${start}, end=${end}} properties for stationRange. ` +
34-
'These stations do not exist on retrieved GTFS route -- ' +
35-
'consider using stationRange.stations property instead.'
36-
);
26+
// If stationIds is empty or undefined, return empty array gracefully
27+
if (!stationIds || stationIds.length === 0) {
28+
return [];
3729
}
3830

39-
if (endIndex !== undefined) {
40-
return stationIds?.slice(startIndex, endIndex + 1);
41-
} else {
42-
throw new Error(`End station ${end} not found in stationIds.`);
31+
const startIndex = stationIds.indexOf(start);
32+
const endIndex = stationIds.indexOf(end);
33+
if (startIndex === -1 || endIndex === -1) {
34+
return [];
4335
}
36+
37+
return stationIds.slice(startIndex, endIndex + 1);
4438
};
4539

4640
const getStationPositions = (
@@ -126,7 +120,8 @@ export const prerenderLine = (
126120
Object.entries(line.routes).forEach(([routeId, { shape }]) => {
127121
const stations = stationsByRoute[routeId];
128122
const routeInfo = routesInfo[routeId];
129-
const stationIds = stations?.map((s) => s.id);
123+
// Safely get station IDs, handling missing/invalid data
124+
const stationIds = Array.isArray(stations) ? stations.map((s) => s.id) : [];
130125
const { pathInterpolator, stationOffsets, pathDirective } = prerenderRoute(
131126
shape,
132127
stationIds
@@ -136,15 +131,17 @@ export const prerenderLine = (
136131
...routeInfo,
137132
id: routeId,
138133
pathInterpolator: pathInterpolator,
139-
stations: stations?.map((station) => {
140-
return {
141-
id: station.id,
142-
name: station.name,
143-
latitude: station.latitude,
144-
longitude: station.longitude,
145-
offset: stationOffsets[station.id],
146-
};
147-
}),
134+
stations: Array.isArray(stations)
135+
? stations.map((station) => {
136+
return {
137+
id: station.id,
138+
name: station.name,
139+
latitude: station.latitude,
140+
longitude: station.longitude,
141+
offset: stationOffsets[station.id],
142+
};
143+
})
144+
: [],
148145
stationPositions: routeStationPositions,
149146
pathDirective,
150147
};

0 commit comments

Comments
 (0)