diff --git a/server/src/nest/maps/pois-along.resolver.ts b/server/src/nest/maps/pois-along.resolver.ts index 0dc04d238..ec1a70ed4 100644 --- a/server/src/nest/maps/pois-along.resolver.ts +++ b/server/src/nest/maps/pois-along.resolver.ts @@ -10,7 +10,9 @@ * Overpass at the same time, one query near each stretch of the whole line, * with a short deadline. A source that fails is logged and its kinds named in * `failed`, so the caller can say what could not be searched instead of - * reporting nothing found. + * reporting nothing found. Once every Overpass endpoint failed, the next + * searches for a few minutes name its kinds at once instead of waiting out the + * deadline each time, while a stretch still cached keeps its places. * * MapsService.poisAlong delegates here and hands in whether the index is * switched on, which is a deployment property it owns. @@ -25,7 +27,7 @@ import { ownedByAsked, } from './maps-index.helpers'; import type { OverpassPoi } from './maps.helpers'; -import { OsmClient } from './providers/osm.client'; +import { OsmClient, OverpassCoolingDown } from './providers/osm.client'; import { trekPlacesNearby } from './trek-places.client'; import { Injectable } from '@nestjs/common'; import type { LatLng } from '@trek/shared/roadtrip'; @@ -89,7 +91,9 @@ function oncePerPlace(pois: OverpassPoi[]): OverpassPoi[] { /** * Requests a few at a time, each batch after the last, so no more than a - * batch is ever in flight; a failed one leaves the others. + * batch is ever in flight; a failed one leaves the others. A stretch refused + * while Overpass cools down is missed like any other, but makes no error of + * its own: the client logs the cooldown once, not with every search. */ async function inBatches(requests: Array<() => Promise>, parallel: number): Promise { const batches = Array.from({ length: Math.ceil(requests.length / parallel) }, (_, index) => @@ -100,13 +104,14 @@ async function inBatches(requests: Array<() => Promise>, parallel: number) Promise.resolve([]), ); const pages = settled.flatMap((entry) => (entry.status === 'fulfilled' ? [entry.value] : [])); - const misses = settled.flatMap((entry) => (entry.status === 'rejected' ? [reason(entry.reason)] : [])); + const misses = settled.flatMap((entry): unknown[] => (entry.status === 'rejected' ? [entry.reason] : [])); + const reported = misses.filter((miss) => !(miss instanceof OverpassCoolingDown)); return { pois: pages.flatMap((page) => page.pois), truncated: pages.some((page) => page.truncated), answered: pages.length, missed: misses.length, - error: misses.length ? `${misses.length} of ${settled.length} requests failed: ${misses[0]}` : null, + error: reported.length ? `${misses.length} of ${settled.length} requests failed: ${reason(reported[0])}` : null, }; } @@ -167,7 +172,9 @@ export class PoisAlongResolver { /** * The other kinds near each stretch of the whole line, one Overpass query - * per stretch; nothing to ask is nothing found. + * per stretch; nothing to ask is nothing found. While the client cools down + * after every Overpass endpoint failed, a stretch it holds no cached answer + * for is refused at once, so its kinds are unavailable without the wait. */ private async askOverpass(kinds: string[], query: PoisAlongQuery): Promise { if (!kinds.length) return NOTHING; diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts index 1fd94d8c0..1a7d68c29 100644 --- a/server/src/nest/maps/providers/osm.client.ts +++ b/server/src/nest/maps/providers/osm.client.ts @@ -11,11 +11,14 @@ * * The POI cache and the mirror list are module state on purpose, frozen at load * the way they were in the service ("frozen on purpose", see - * src/app-config/README.md), so every instance shares one cache. + * src/app-config/README.md), so every instance shares one cache. The pause the + * search along a line takes after every Overpass endpoint failed it is the + * instance's own: MapsModule provides one client per process. * * Never imports MapsService: providers sit below the orchestrator * (lint:boundaries holds that). */ +import { logWarn } from '../../audit/audit-log.logger'; import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client'; import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers'; import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers'; @@ -317,8 +320,34 @@ function readPois( */ const OVERPASS_ALONG_TIMEOUT_MS = (OVERPASS_ALONG_QUERY_TIMEOUT_S + 2) * 1000; +/** + * How long the search along a line leaves Overpass alone once every endpoint + * failed it. While the public mirrors do not answer, each search would wait + * out the whole deadline only to name the same kinds unavailable; after the + * pause the next search asks again for real. + */ +export const OVERPASS_ALONG_COOLDOWN_MS = 3 * 60 * 1000; + +/** + * A stretch of a search along a line refused during the cooldown: nothing was + * asked, and the cooldown is logged once rather than with every search. + */ +export class OverpassCoolingDown extends Error { + constructor() { + super('Overpass unreachable, not retrying yet'); + this.name = 'OverpassCoolingDown'; + } +} + @Injectable() export class OsmClient { + /** Until when the search along a line leaves Overpass alone after every endpoint failed it; 0 while it asks. */ + private overpassAlongCooldownUntil = 0; + /** When an along query was last answered, so a failure that settles after an answer starts no cooldown. */ + private overpassAlongAnsweredAt = 0; + /** Whether this cooldown's first skip was logged, so the log holds one line per cooldown. */ + private overpassAlongSkipLogged = false; + // ── Nominatim search ─────────────────────────────────────────────────────── /** @@ -721,7 +750,9 @@ export class OsmClient { * the cap is spent on the corridor rather than on the box. Unnamed taps, * huts and the like stay, because they are what a hike looks for, while an * unnamed garden or cafe is dropped as on the explore pill. A short deadline - * of its own; failing throws as the explore pill's search does. + * of its own; failing throws as the explore pill's search does. During the + * cooldown after every endpoint failed, a stretch still cached is answered + * from the cache and any other throws OverpassCoolingDown at once. */ async searchOverpassPoisAlong( category: string, @@ -739,8 +770,10 @@ export class OsmClient { const cacheKey = `along|${kinds}|${osmLang}|${path.radiusM}|${points}|${cap}`; const cached = cachedPoiAnswer(cacheKey); if (cached) return cached; + // Neither a new search nor a later stretch of one under way waits for mirrors that just failed. + if (this.skipsOverpassAlong()) throw new OverpassCoolingDown(); - const elements = await overpassFetch(overpassAlongQuery(match.filters, path, cap), OVERPASS_ALONG_TIMEOUT_MS); + const elements = await this.fetchOverpassAlong(overpassAlongQuery(match.filters, path, cap)); const pois = readPois(elements, match, osmLang, KEPT_UNNAMED_ALONG); const value: PoiSearchResult = { pois: pois.slice(0, cap), @@ -751,4 +784,42 @@ export class OsmClient { cachePoiAnswer(cacheKey, value); return value; } + + /** + * Whether the search along a line leaves Overpass alone right now, because + * every endpoint failed it within the last OVERPASS_ALONG_COOLDOWN_MS. Its + * kinds are then unavailable at once instead of after the whole deadline. + * The first skip of each cooldown is logged. + */ + skipsOverpassAlong(): boolean { + if (Date.now() >= this.overpassAlongCooldownUntil) return false; + if (!this.overpassAlongSkipLogged) { + this.overpassAlongSkipLogged = true; + const until = new Date(this.overpassAlongCooldownUntil).toISOString(); + logWarn(`[Maps] OpenStreetMap along a line: every Overpass endpoint failed, so it is not asked until ${until}`); + } + return true; + } + + /** + * The along query on the mirrors. An answer ends the cooldown; every + * endpoint failing starts it, unless another along query was answered while + * this one was in flight: Overpass is up then, and only this query failed. + */ + private async fetchOverpassAlong(query: string): Promise { + const askedAt = Date.now(); + try { + const elements = await overpassFetch(query, OVERPASS_ALONG_TIMEOUT_MS); + this.overpassAlongAnsweredAt = Date.now(); + this.overpassAlongCooldownUntil = 0; + return elements; + } catch (err) { + // overpassFetch throws only once every mirror has failed. + if (this.overpassAlongAnsweredAt < askedAt) { + this.overpassAlongCooldownUntil = Date.now() + OVERPASS_ALONG_COOLDOWN_MS; + this.overpassAlongSkipLogged = false; + } + throw err; + } + } } diff --git a/server/tests/unit/nest/maps.pois-along.test.ts b/server/tests/unit/nest/maps.pois-along.test.ts index f2faac493..c6c6143dc 100644 --- a/server/tests/unit/nest/maps.pois-along.test.ts +++ b/server/tests/unit/nest/maps.pois-along.test.ts @@ -1,7 +1,9 @@ /** * MAPS-ALONG-010..019: places near a line (PLN-02), from the index in circles * along it and from Overpass, in stretches of the whole line, for the kinds - * the index does not know. + * the index does not know. After every Overpass mirror failed, its kinds are + * named unavailable at once for a cooldown instead of after its deadline, + * unless Overpass answered meanwhile or the stretch is still cached. * * The index is asked through a stub here, Overpass through a spy on the OSM * client: these cases are about who is asked for what, and how a source that @@ -10,6 +12,7 @@ import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { ownedByAsked } from '../../../src/nest/maps/maps-index.helpers'; +import { OVERPASS_ALONG_COOLDOWN_MS } from '../../../src/nest/maps/providers/osm.client'; import { INDEX_MAX_CATEGORY_CHARS, INDEX_MAX_LIMIT } from '../../../src/nest/maps/trek-places.client'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; @@ -103,12 +106,19 @@ function make(enabled = true) { const overpass = vi .spyOn(osm, 'searchOverpassPoisAlong') .mockResolvedValue({ pois: [SPRING], source: 'openstreetmap', truncated: false, clamped: false }); - return { svc, overpass }; + return { svc, overpass, osm }; } /** The lines logged as warnings. */ const mapsWarnings = () => logWarn.mock.calls.map((call) => String(call[0])); +/** What every Overpass mirror answers while it is down. */ +const DOWN = { ok: false, status: 504, json: async () => ({}) }; + +/** How many different Overpass queries a stubbed fetch was sent; each goes to every mirror. */ +const queriesSent = (fetchMock: { mock: { calls: unknown[][] } }) => + new Set(fetchMock.mock.calls.map((call) => decodeURIComponent(String((call[1] as RequestInit).body)))).size; + /** A line due north from 47°N 11°E, `degrees` of latitude long: about 111 km each. */ const north = (degrees: number) => [ { lat: 47, lng: 11 }, @@ -359,6 +369,55 @@ describe('MapsService.poisAlong', () => { expect(seen.most).toBe(3); }); + it('MAPS-ALONG-019c: after every Overpass mirror failed, the next searches name its kinds at once for a while', async () => { + vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z') }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + // Mirrors that never answer: each request waits out its deadline and is aborted. + const fetchMock = vi.fn( + (_url: unknown, init?: RequestInit) => + new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(new Error('aborted')))), + ); + vi.stubGlobal('fetch', fetchMock); + mockNearby.mockResolvedValue([row('c-1', 'Kaffeehaus', CAFE)]); + const { svc, overpass } = make(); + overpass.mockRestore(); + try { + const first = svc.poisAlong(['cafe', 'viewpoint', 'drinking_water'], LINE, 500, 'de', 30); + await vi.advanceTimersByTimeAsync(15_000); + expect(await first).toMatchObject({ failed: ['viewpoint', 'drinking_water'], partial: false }); + const asked = fetchMock.mock.calls.length; + + // Without waiting for any deadline: nothing is asked, the index places stay, the kinds are named. + const again = await svc.poisAlong(['cafe', 'viewpoint', 'drinking_water'], LINE, 500, 'de', 30); + const hike = await svc.poisAlong(['viewpoint', 'shelter'], north(0.1), 500, 'de', 30); + expect(fetchMock).toHaveBeenCalledTimes(asked); + expect(again).toMatchObject({ failed: ['viewpoint', 'drinking_water'], partial: false }); + expect(again.pois.map((poi) => poi.name)).toEqual(['Kaffeehaus']); + expect(hike.failed).toEqual(['viewpoint', 'shelter']); + // The search that waited out its twelve seconds says why; the skips after it are one line for the + // whole cooldown, three minutes from that failure. + expect(mapsWarnings()).toEqual([ + expect.stringContaining('[Maps] OpenStreetMap along a line: viewpoint, drinking_water: 1 of 1 requests failed'), + expect.stringContaining('every Overpass endpoint failed, so it is not asked until 2026-10-19T10:03:12.000Z'), + ]); + + // After the cooldown it is asked again for real, and an answer ends the pause for good. + vi.advanceTimersByTime(OVERPASS_ALONG_COOLDOWN_MS); + fetchMock.mockResolvedValue({ ok: true, json: async () => ({ elements: [] }) }); + const later = await svc.poisAlong(['viewpoint'], LINE, 500, 'de', 30); + expect(later.failed).toEqual([]); + expect(fetchMock.mock.calls.length).toBeGreaterThan(asked); + const answered = fetchMock.mock.calls.length; + await svc.poisAlong(['shelter'], LINE, 500, 'de', 30); + expect(fetchMock.mock.calls.length).toBeGreaterThan(answered); + expect(mapsWarnings()).toHaveLength(2); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + it('MAPS-ALONG-019b: one stretch failing keeps the places of the others and marks the answer partial', async () => { const { svc, overpass } = make(); overpass.mockRejectedValueOnce(new Error('rate limited')); @@ -372,4 +431,95 @@ describe('MapsService.poisAlong', () => { ), ]); }); + + it('MAPS-ALONG-019d: a stretch failing after the others answered starts no cooldown, so the rest of a long line is asked', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + // The first of five stretches fails on every mirror, after the two asked beside it answered. + const fetchMock = vi.fn(async (_url: unknown, init?: RequestInit) => { + const first = decodeURIComponent(String(init?.body)).includes('47.00000,11.00000'); + await new Promise((resolve) => setTimeout(resolve, first ? 30 : 5)); + return first ? DOWN : { ok: true, json: async () => ({ elements: [] }) }; + }); + vi.stubGlobal('fetch', fetchMock); + const { svc, overpass, osm } = make(); + overpass.mockRestore(); + try { + const out = await svc.poisAlong(['viewpoint'], north(2), 500, 'de', 30); + + expect(out).toMatchObject({ failed: [], partial: true }); + // Stretches 4 and 5 are asked as well: Overpass answered while the first one was failing. + expect(queriesSent(fetchMock)).toBe(5); + expect(osm.skipsOverpassAlong()).toBe(false); + expect(mapsWarnings()).toEqual([ + expect.stringContaining('OpenStreetMap along a line: viewpoint: 1 of 5 requests failed: Could not reach any'), + ]); + } finally { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + + it('MAPS-ALONG-019e: a line answered before the cooldown keeps its Overpass places during it, from the cache', async () => { + vi.useFakeTimers({ now: new Date('2026-10-19T11:00:00Z'), toFake: ['Date'] }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const view = { type: 'node', id: 77, lat: 47.01, lon: 12, tags: { tourism: 'viewpoint', name: 'Aussicht' } }; + const fetchMock = vi + .fn<(url: unknown, init?: RequestInit) => Promise>() + .mockResolvedValue({ ok: true, json: async () => ({ elements: [view] }) }); + vi.stubGlobal('fetch', fetchMock); + const { svc, overpass } = make(); + overpass.mockRestore(); + const at = (lng: number) => [ + { lat: 47, lng }, + { lat: 47.0269, lng }, + ]; + try { + const first = await svc.poisAlong(['viewpoint'], at(12), 500, 'de', 30); + expect(first.pois.map((poi) => poi.name)).toEqual(['Aussicht']); + + // A second later every mirror fails another line, and the cooldown starts. + vi.setSystemTime(new Date('2026-10-19T11:00:01Z')); + fetchMock.mockResolvedValue(DOWN); + expect((await svc.poisAlong(['viewpoint'], at(12.5), 500, 'de', 30)).failed).toEqual(['viewpoint']); + const asked = fetchMock.mock.calls.length; + + // A minute on, the first line is answered from the cache as before; a line not asked yet is named at once. + vi.setSystemTime(new Date('2026-10-19T11:01:01Z')); + const again = await svc.poisAlong(['viewpoint'], at(12), 500, 'de', 30); + expect(again).toMatchObject({ failed: [], partial: false }); + expect(again.pois.map((poi) => poi.name)).toEqual(['Aussicht']); + expect((await svc.poisAlong(['viewpoint'], at(13), 500, 'de', 30)).failed).toEqual(['viewpoint']); + expect(fetchMock).toHaveBeenCalledTimes(asked); + expect(mapsWarnings()).toEqual([ + expect.stringContaining('OpenStreetMap along a line: viewpoint: 1 of 1 requests failed: Could not reach any'), + expect.stringContaining('every Overpass endpoint failed, so it is not asked until 2026-10-19T11:03:01.000Z'), + ]); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + + it('MAPS-ALONG-019f: the stretches a cooldown leaves out are counted, and the log names why the first ones failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchMock = vi.fn(async (_url: unknown, _init?: RequestInit) => DOWN); + vi.stubGlobal('fetch', fetchMock); + const { svc, overpass } = make(); + overpass.mockRestore(); + try { + const out = await svc.poisAlong(['shelter'], north(2), 500, 'de', 30); + + expect(out).toMatchObject({ pois: [], failed: ['shelter'], partial: false }); + // Stretches 4 and 5 are not sent: every mirror had just failed the first three. + expect(queriesSent(fetchMock)).toBe(3); + expect(mapsWarnings()).toEqual([ + expect.stringContaining('every Overpass endpoint failed, so it is not asked until'), + expect.stringContaining('OpenStreetMap along a line: shelter: 5 of 5 requests failed: Could not reach any'), + ]); + } finally { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); }); diff --git a/server/tests/unit/nest/osm.pois-along.test.ts b/server/tests/unit/nest/osm.pois-along.test.ts index 371ad980f..b525240d6 100644 --- a/server/tests/unit/nest/osm.pois-along.test.ts +++ b/server/tests/unit/nest/osm.pois-along.test.ts @@ -1,15 +1,29 @@ /** - * MAPS-ALONG-020..026: the Overpass query near a line (PLN-02). One query for + * MAPS-ALONG-020..029: the Overpass query near a line (PLN-02). One query for * every kind the index does not know, the candidates of the line's box kept * within reach of the line itself, and unnamed taps and huts kept, because - * they are what a hike looks for, while an unnamed garden or cafe is not. The - * network is a stubbed global fetch. + * they are what a hike looks for, while an unnamed garden or cafe is not. + * After every mirror failed, the next queries are refused at once for a + * cooldown, unless Overpass answered meanwhile or the answer is still cached. + * The network is a stubbed global fetch. */ -import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; +import { + OVERPASS_ALONG_COOLDOWN_MS, + OsmClient, + OverpassCoolingDown, +} from '../../../src/nest/maps/providers/osm.client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const osm = new OsmClient(); +const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() })); +// The skip after every mirror failed is logged once per cooldown; the logger itself writes to trek.log. +vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({ + ...(await importOriginal()), + logWarn, +})); + +/** A client of its own per case: the cooldown after every mirror failed is the instance's state. */ +let osm: OsmClient; /** A line of two points, a little apart per case, so no case is answered from another's cache. */ const line = (n: number) => [ @@ -19,11 +33,19 @@ const line = (n: number) => [ const answer = (elements: unknown[]) => ({ ok: true, json: async () => ({ elements }) }); +/** What every mirror answers while Overpass is down. */ +const down = () => ({ ok: false, status: 504, json: async () => ({}) }); + +/** The line a skip writes, once per cooldown, before the time it ends. */ +const SKIP_LOGGED = '[Maps] OpenStreetMap along a line: every Overpass endpoint failed, so it is not asked until'; + /** The query of a fetch call, read back the way Overpass reads the form. */ const queryOf = (call: unknown[] | undefined) => decodeURIComponent(String((call?.[1] as RequestInit | undefined)?.body ?? '')).replace(/^data=/, ''); beforeEach(() => { + osm = new OsmClient(); + logWarn.mockClear(); vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -93,11 +115,105 @@ describe('OsmClient.searchOverpassPoisAlong', () => { }); it('MAPS-ALONG-023: every mirror down throws, so the caller can name the kinds as unavailable', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 504, json: async () => ({}) })); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(down())); await expect(osm.searchOverpassPoisAlong('viewpoint', line(3), 500)).rejects.toMatchObject({ status: 502 }); }); + it('MAPS-ALONG-027: after every mirror failed it is not asked for a cooldown, logged once, then asked again', async () => { + const fetchMock = vi.fn().mockResolvedValue(down()); + vi.stubGlobal('fetch', fetchMock); + vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z'), toFake: ['Date'] }); + try { + await expect(osm.searchOverpassPoisAlong('viewpoint', line(7), 500)).rejects.toMatchObject({ status: 502 }); + const asked = fetchMock.mock.calls.length; + expect(asked).toBeGreaterThan(0); + + // Inside the cooldown no line and no kind is asked, and the skip is logged once. + vi.setSystemTime(new Date(Date.now() + OVERPASS_ALONG_COOLDOWN_MS - 1)); + await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toThrow('not retrying yet'); + await expect(osm.searchOverpassPoisAlong('drinking_water', line(7), 500)).rejects.toThrow('not retrying yet'); + expect(osm.skipsOverpassAlong()).toBe(true); + // An unknown kind is still refused as one. + await expect(osm.searchOverpassPoisAlong('unicorns', line(7), 500)).rejects.toMatchObject({ status: 400 }); + expect(fetchMock).toHaveBeenCalledTimes(asked); + expect(logWarn).toHaveBeenCalledTimes(1); + expect(logWarn).toHaveBeenCalledWith(`${SKIP_LOGGED} 2026-10-19T10:03:00.000Z`); + + // Three minutes on it is asked again for real; still down, it starts a new cooldown with a line of its own. + vi.setSystemTime(new Date('2026-10-19T10:03:00Z')); + expect(osm.skipsOverpassAlong()).toBe(false); + await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toMatchObject({ status: 502 }); + expect(fetchMock).toHaveBeenCalledTimes(2 * asked); + await expect(osm.searchOverpassPoisAlong('viewpoint', line(8), 500)).rejects.toThrow('not retrying yet'); + expect(logWarn).toHaveBeenCalledTimes(2); + expect(logWarn).toHaveBeenLastCalledWith(`${SKIP_LOGGED} 2026-10-19T10:06:00.000Z`); + } finally { + vi.useRealTimers(); + } + }); + + it('MAPS-ALONG-028: an answer ends the cooldown, so the next search asks at once', async () => { + // The first line's query fails on every mirror at once, the second one's answers a moment later. + const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => { + if (queryOf([url, init]).includes('47.49000')) return down(); + await new Promise((resolve) => setTimeout(resolve, 5)); + return answer([]); + }); + vi.stubGlobal('fetch', fetchMock); + + await Promise.all([ + expect(osm.searchOverpassPoisAlong('viewpoint', line(9), 500)).rejects.toMatchObject({ status: 502 }), + expect(osm.searchOverpassPoisAlong('viewpoint', line(11), 500)).resolves.toMatchObject({ pois: [] }), + ]); + const asked = fetchMock.mock.calls.length; + + expect(osm.skipsOverpassAlong()).toBe(false); + await osm.searchOverpassPoisAlong('viewpoint', line(13), 500); + expect(fetchMock.mock.calls.length).toBeGreaterThan(asked); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it('MAPS-ALONG-029: a query failing after another was answered starts no cooldown, one sent after the answer does', async () => { + vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z') }); + // The query along line(15) is answered after a second, every other one fails on every mirror after three. + const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => { + const answers = queryOf([url, init]).includes('47.55000,11.10000'); + await new Promise((resolve) => setTimeout(resolve, answers ? 1000 : 3000)); + return answers ? answer([]) : down(); + }); + vi.stubGlobal('fetch', fetchMock); + try { + const answered = osm.searchOverpassPoisAlong('viewpoint', line(15), 500); + const failed = expect(osm.searchOverpassPoisAlong('viewpoint', line(17), 500)).rejects.toMatchObject({ + status: 502, + }); + await vi.advanceTimersByTimeAsync(5000); + await expect(answered).resolves.toMatchObject({ pois: [] }); + await failed; + // Overpass answered while the failing query was in flight: it is up, and the next search asks. + expect(osm.skipsOverpassAlong()).toBe(false); + const asked = fetchMock.mock.calls.length; + const again = expect(osm.searchOverpassPoisAlong('viewpoint', line(17), 500)).rejects.toMatchObject({ + status: 502, + }); + await vi.advanceTimersByTimeAsync(5000); + await again; + expect(fetchMock.mock.calls.length).toBeGreaterThan(asked); + + // That query was sent after the last answer and failed: now the cooldown starts. + expect(osm.skipsOverpassAlong()).toBe(true); + await expect(osm.searchOverpassPoisAlong('viewpoint', line(19), 500)).rejects.toBeInstanceOf(OverpassCoolingDown); + // A line still cached is answered from the cache during it. + const sent = fetchMock.mock.calls.length; + await expect(osm.searchOverpassPoisAlong('viewpoint', line(15), 500)).resolves.toMatchObject({ pois: [] }); + expect(fetchMock).toHaveBeenCalledTimes(sent); + expect(logWarn).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('MAPS-ALONG-024b: an answer older than five minutes is asked again, and the oldest of a full cache goes', async () => { const fetchMock = vi.fn().mockResolvedValue(answer([])); vi.stubGlobal('fetch', fetchMock);