Skip to content

Commit 0bb3636

Browse files
committed
fix: cover geocode response bodies with deadline (#160)
1 parent 3a7a496 commit 0bb3636

4 files changed

Lines changed: 27 additions & 8 deletions

File tree

server/src/routes/map.routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ mapRouter.get('/geocode/stream', async (req: Request, res: Response) => {
9494
return 'error';
9595
});
9696

97-
if (streamResult === 'ok') {
97+
if (streamResult === 'ok' && !cancelled) {
9898
sendEvent({ type: 'complete', current: placesToGeocode.length, total: placesToGeocode.length });
9999
}
100100

server/src/services/geocode.service.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ function ensurePending(placeText: string): void {
9797
}
9898

9999
type FetchResult = { status: 'found'; result: NominatimResult } | { status: 'not_found' } | { status: 'error' } | { status: 'cancelled' };
100+
type NominatimResponse = { status: number; ok: boolean; data: NominatimResult[] };
100101

101-
async function fetchWithDeadline(url: string, query: string, callerSignal?: AbortSignal): Promise<Response | null | 'cancelled'> {
102+
async function fetchWithDeadline(url: string, query: string, callerSignal?: AbortSignal): Promise<NominatimResponse | null | 'cancelled'> {
102103
if (callerSignal?.aborted) return 'cancelled';
103104

104105
const controller = new AbortController();
@@ -111,7 +112,13 @@ async function fetchWithDeadline(url: string, query: string, callerSignal?: Abor
111112
}, REQUEST_TIMEOUT_MS);
112113

113114
try {
114-
return await fetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: controller.signal });
115+
const response = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, signal: controller.signal });
116+
if (response.status === 429) {
117+
void response.body?.cancel().catch(() => {});
118+
return { status: response.status, ok: response.ok, data: [] };
119+
}
120+
const data = response.ok ? await response.json() as NominatimResult[] : [];
121+
return { status: response.status, ok: response.ok, data };
115122
} catch (error) {
116123
if (callerSignal?.aborted) {
117124
logger.warn('geocode', `Geocode request status=cancelled place=${JSON.stringify(query)} reason=caller_cancelled`);
@@ -147,13 +154,11 @@ export function fetchNominatim(query: string, signal?: AbortSignal): Promise<Fet
147154
const retry = await fetchWithDeadline(url, query, signal);
148155
if (retry === 'cancelled') return { status: 'cancelled' };
149156
if (!retry?.ok) return { status: 'error' };
150-
const retryData: NominatimResult[] = await retry.json();
151-
return retryData[0] ? { status: 'found', result: retryData[0] } : { status: 'not_found' };
157+
return retry.data[0] ? { status: 'found', result: retry.data[0] } : { status: 'not_found' };
152158
}
153159

154160
if (!response.ok) return { status: 'error' };
155-
const data: NominatimResult[] = await response.json();
156-
return data[0] ? { status: 'found', result: data[0] } : { status: 'not_found' };
161+
return response.data[0] ? { status: 'found', result: response.data[0] } : { status: 'not_found' };
157162
} catch (error) {
158163
if (signal?.aborted) return { status: 'cancelled' };
159164
logger.warn('geocode', `Geocode response status=error place=${JSON.stringify(query)} reason=response_error`);

tests/unit/routes/mapGeocodeStream.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ describe('map geocode stream cancellation', () => {
2424
batchGeocode.mockImplementation(async function* (_places: string[], signal?: AbortSignal) {
2525
receivedSignal = signal;
2626
await new Promise<void>(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }));
27-
yield { type: 'progress', current: 1, total: 2, place: 'First place', status: 'resolved' };
2827
});
2928

3029
const layer = (mapRouter as unknown as { stack: Array<{ route?: { path: string; stack: Array<{ handle: (req: unknown, res: unknown) => Promise<void> }> } }> }).stack

tests/unit/services/geocode.service.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ describe('geocodeService Nominatim lifecycle', () => {
4949
expect(fetchMock).toHaveBeenCalledTimes(2);
5050
});
5151

52+
it('keeps the deadline active while reading a stalled response body', async () => {
53+
const fetchMock = vi.fn((_url: string, init: RequestInit) => ({
54+
status: 200,
55+
ok: true,
56+
json: () => new Promise((_resolve, reject) => {
57+
init.signal?.addEventListener('abort', () => reject(new DOMException('Timed out', 'AbortError')));
58+
}),
59+
}));
60+
vi.stubGlobal('fetch', fetchMock);
61+
62+
const request = fetchNominatim('Slow body place');
63+
await vi.advanceTimersByTimeAsync(1_100 + 15_000);
64+
await expect(request).resolves.toEqual({ status: 'error' });
65+
});
66+
5267
it('does not make another upstream request after a batch is cancelled', async () => {
5368
const fetchMock = vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
5469
init.signal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')));

0 commit comments

Comments
 (0)