diff --git a/README.md b/README.md index 881cfac..4ecc287 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ npm run build # сборка в dist/ — можно положить на npm run typecheck ``` +Для автоматических проверок сетевого клиента нужен Node.js 22+: `npm test`. +Тесты проверяют таймауты, переключение серверов и отмену без обращения к сети. + +Данные сначала запрашиваются у `maps.mail.ru`, затем у `overpass-api.de` и +`overpass.private.coffee`. На каждый запрос отводится 45 секунд, включая загрузку +ответа; после ошибки или таймаута используется следующий сервер. Лимит обработки +запроса на стороне Overpass — 30 секунд. Кнопка «Отмена» прерывает запрос и ожидание +перед повтором. + Без интернета можно проверить на образце: «Другие способы» → «Сгенерировать из выгрузки Overpass» → `samples/balzers-2013.json` (Бальцерс, Лихтенштейн, данные OSM 2013 года). diff --git a/package.json b/package.json index 3825089..164a3a7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Генератор подробной карты города по данным OpenStreetMap: планировки, этажи и мебель простыми фигурами, вид сверху.", "type": "module", "scripts": { + "test": "tsx --test scripts/*.test.ts", "dev": "node build.mjs --serve", "build": "node build.mjs", "typecheck": "tsc -p .", diff --git a/scripts/overpass.test.ts b/scripts/overpass.test.ts new file mode 100644 index 0000000..a7bbedb --- /dev/null +++ b/scripts/overpass.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { fetchOverpass, OVERPASS_ENDPOINTS, type BBox } from '../src/osm/overpass'; + +const bbox: BBox = [35.3, -112.9, 35.31, -112.89]; +const elements = [{ type: 'node', id: 1 }]; +const ok = () => Response.json({ elements }); +const pending = (signal: AbortSignal) => new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); +}); + +test('starts with Mail.ru and falls back after an HTTP error', async (t) => { + const urls: string[] = []; + t.mock.method(globalThis, 'fetch', async (url: string) => { + urls.push(url); + return urls.length === 1 ? new Response('', { status: 406 }) : ok(); + }); + assert.deepEqual(await fetchOverpass(bbox), elements); + assert.match(urls[0], /maps\.mail\.ru/); + assert.deepEqual(urls, OVERPASS_ENDPOINTS.slice(0, 2)); +}); + +test('times out a hanging request and tries the next server', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => { + return ++calls === 1 ? pending(init.signal!) : Promise.resolve(ok()); + }); + const result = fetchOverpass(bbox); + t.mock.timers.tick(45_000); + assert.deepEqual(await result, elements); + assert.equal(calls, 2); +}); + +test('timeout also covers downloading the response body', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => { + if (++calls > 1) return ok(); + return { ok: true, status: 200, json: () => pending(init.signal!) }; + }); + const result = fetchOverpass(bbox); + await Promise.resolve(); + t.mock.timers.tick(45_000); + assert.deepEqual(await result, elements); + assert.equal(calls, 2); +}); + +test('user cancellation stops the active request without fallback', async (t) => { + const controller = new AbortController(); + const fetch = t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => pending(init.signal!)); + const result = fetchOverpass(bbox, undefined, controller.signal); + controller.abort(); + await assert.rejects(result, { name: 'AbortError' }); + assert.equal(fetch.mock.callCount(), 1); +}); + +test('user cancellation interrupts the retry delay', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const controller = new AbortController(); + const fetch = t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 429 })); + const result = fetchOverpass(bbox, undefined, controller.signal); + await Promise.resolve(); + controller.abort(); + await assert.rejects(result, { name: 'AbortError' }); + assert.equal(fetch.mock.callCount(), 1); +}); + +test('an already cancelled operation makes no requests', async (t) => { + const fetch = t.mock.method(globalThis, 'fetch', async () => ok()); + await assert.rejects(fetchOverpass(bbox, undefined, AbortSignal.abort()), { name: 'AbortError' }); + assert.equal(fetch.mock.callCount(), 0); +}); + +test('reports failure when every server times out', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fetch = t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => pending(init.signal!)); + const result = fetchOverpass(bbox); + const check = assert.rejects(result, /Таймаут.*45 секунд/); + for (let i = 0; i < OVERPASS_ENDPOINTS.length; i++) { + t.mock.timers.tick(45_000); + await Promise.resolve(); + } + await check; + assert.equal(fetch.mock.callCount(), OVERPASS_ENDPOINTS.length); +}); diff --git a/src/osm/overpass.ts b/src/osm/overpass.ts index dcebb86..2ba5f7e 100644 --- a/src/osm/overpass.ts +++ b/src/osm/overpass.ts @@ -15,14 +15,32 @@ export interface OsmElement { } export const OVERPASS_ENDPOINTS = [ + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', 'https://overpass-api.de/api/interpreter', 'https://overpass.private.coffee/api/interpreter', - 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', ]; +const REQUEST_TIMEOUT_MS = 45_000; + +function waitForRetry(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const cancel = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); + reject(new DOMException('Отменено', 'AbortError')); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', cancel); + resolve(); + }, ms); + signal?.addEventListener('abort', cancel, { once: true }); + if (signal?.aborted) cancel(); + }); +} + export function buildQuery(b: BBox): string { const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`; - return `[out:json][timeout:120]; + return `[out:json][timeout:30]; ( way["building"]${bb}; relation["building"]["type"="multipolygon"]${bb}; @@ -56,27 +74,42 @@ export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, sig let lastErr: unknown = null; for (const url of OVERPASS_ENDPOINTS) { for (let attempt = 0; attempt < 2; attempt++) { + if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); + const controller = new AbortController(); + const cancel = () => controller.abort(); + signal?.addEventListener('abort', cancel, { once: true }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); try { - onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''}…`); + onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до 45 секунд…`); const res = await fetch(url, { method: 'POST', body: 'data=' + encodeURIComponent(query), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - signal, + signal: controller.signal, }); if (res.status === 429 || res.status === 504) { lastErr = new Error(`Сервер перегружен (${res.status})`); - await new Promise((r) => setTimeout(r, 3000 * (attempt + 1))); + clearTimeout(timer); + if (attempt === 0) await waitForRetry(3000, signal); continue; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); + if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); if (json.remark && /error|timed out|runtime/i.test(json.remark)) throw new Error(json.remark); + if (!Array.isArray(json.elements)) throw new Error('Сервер вернул ответ без списка elements'); return json.elements as OsmElement[]; } catch (e) { - if (signal?.aborted) throw e; - lastErr = e; + if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); + lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: 45 секунд`) : e; break; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', cancel); } } }