Fix Overpass timeouts, cancellation and endpoint priority
This commit is contained in:
1 parent
2d4bad0dc7
commit
9487bc23bd
4 files changed
+136
-7
No files matched your search
@@ -19,6 +19,15 @@ npm run build # сборка в dist/ — можно положить на
|
|||||||
npm run typecheck
|
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 года).
|
Overpass» → `samples/balzers-2013.json` (Бальцерс, Лихтенштейн, данные OSM 2013 года).
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Генератор подробной карты города по данным OpenStreetMap: планировки, этажи и мебель простыми фигурами, вид сверху.",
|
"description": "Генератор подробной карты города по данным OpenStreetMap: планировки, этажи и мебель простыми фигурами, вид сверху.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "tsx --test scripts/*.test.ts",
|
||||||
"dev": "node build.mjs --serve",
|
"dev": "node build.mjs --serve",
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
"typecheck": "tsc -p .",
|
"typecheck": "tsc -p .",
|
||||||
|
|||||||
@@ -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<Response>((_, 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);
|
||||||
|
});
|
||||||
+40
-7
@@ -15,14 +15,32 @@ export interface OsmElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OVERPASS_ENDPOINTS = [
|
export const OVERPASS_ENDPOINTS = [
|
||||||
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||||
'https://overpass-api.de/api/interpreter',
|
'https://overpass-api.de/api/interpreter',
|
||||||
'https://overpass.private.coffee/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<void> {
|
||||||
|
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 {
|
export function buildQuery(b: BBox): string {
|
||||||
const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`;
|
const bb = `(${b[0]},${b[1]},${b[2]},${b[3]})`;
|
||||||
return `[out:json][timeout:120];
|
return `[out:json][timeout:30];
|
||||||
(
|
(
|
||||||
way["building"]${bb};
|
way["building"]${bb};
|
||||||
relation["building"]["type"="multipolygon"]${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;
|
let lastErr: unknown = null;
|
||||||
for (const url of OVERPASS_ENDPOINTS) {
|
for (const url of OVERPASS_ENDPOINTS) {
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
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 {
|
try {
|
||||||
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''}…`);
|
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до 45 секунд…`);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: 'data=' + encodeURIComponent(query),
|
body: 'data=' + encodeURIComponent(query),
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (res.status === 429 || res.status === 504) {
|
if (res.status === 429 || res.status === 504) {
|
||||||
lastErr = new Error(`Сервер перегружен (${res.status})`);
|
lastErr = new Error(`Сервер перегружен (${res.status})`);
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
clearTimeout(timer);
|
||||||
|
if (attempt === 0) await waitForRetry(3000, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const json = await res.json();
|
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 (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[];
|
return json.elements as OsmElement[];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) throw e;
|
if (signal?.aborted) throw new DOMException('Отменено', 'AbortError');
|
||||||
lastErr = e;
|
lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: 45 секунд`) : e;
|
||||||
break;
|
break;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', cancel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user