fix(tours): wait for the public Valhalla's turn before every request

The public instance allows about one request a second per client, and the
Tours elevation questions went out right behind the route request with no
pause, so they could be refused with a 429 and leave the tour without the
elevation it needs to be saved. Every request to that host now waits for
its turn, across callers; an operator's own Valhalla is not paced.
This commit is contained in:
Maurice
2026-10-07 21:39:48 +02:00
parent e0efa0314a
commit e5ceba00f3
4 changed files with 118 additions and 4 deletions
@@ -1,8 +1,8 @@
import { describe, it, expect, afterEach } from 'vitest'
import { describe, it, expect, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -37,6 +37,55 @@ const setSettings = (patch: Record<string, string>) =>
afterEach(() => {
setSettings({ routing_base_url: '', valhalla_base_url: '' })
resetValhallaTurns()
})
describe('valhallaTurn', () => {
const PUBLIC = 'https://valhalla1.openstreetmap.de'
afterEach(() => vi.useRealTimers())
it('spaces questions to the public instance a little over a second apart, across callers', async () => {
vi.useFakeTimers()
const done: number[] = []
const start = Date.now()
await valhallaTurn(PUBLIC)
done.push(Date.now() - start)
const second = valhallaTurn(PUBLIC).then(() => done.push(Date.now() - start))
const third = valhallaTurn(PUBLIC).then(() => done.push(Date.now() - start))
await vi.advanceTimersByTimeAsync(5000)
await Promise.all([second, third])
expect(done[0]).toBe(0)
expect(done[1]).toBeGreaterThanOrEqual(1100)
expect(done[2] - done[1]).toBeGreaterThanOrEqual(1100)
})
it("does not pace an operator's own host", async () => {
vi.useFakeTimers()
await valhallaTurn('https://valhalla.example.org')
let through = false
void valhallaTurn('https://valhalla.example.org').then(() => { through = true })
await vi.advanceTimersByTimeAsync(0)
expect(through).toBe(true)
})
it('lets a caller that gives up go without taking the turn', async () => {
vi.useFakeTimers()
await valhallaTurn(PUBLIC)
const controller = new AbortController()
const waiting = valhallaTurn(PUBLIC, controller.signal)
controller.abort()
await vi.advanceTimersByTimeAsync(0)
await waiting
// The abandoned wait claimed nothing, so the next caller only waits out the first turn.
const start = Date.now()
const next = valhallaTurn(PUBLIC)
await vi.advanceTimersByTimeAsync(1100)
await next
expect(Date.now() - start).toBeLessThanOrEqual(1100)
})
})
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
@@ -254,6 +254,35 @@ function pause(ms: number, signal?: AbortSignal): Promise<void> {
})
}
/** When this tab last put a question to the public instance. */
let lastPublicAskAt = 0
/**
* Waits for this tab's turn at the public instance, which allows about one request a
* second per client. Every request to it goes through here, the route and the Tours
* elevation alike, so a tour's route and the height question right behind it, or two
* features asking at once, no longer reach it inside the same second and come back 429.
* The pauses the callers keep themselves still stand; this only adds the wait they
* cannot see, the one between unrelated callers.
*
* Another host is not paced: an operator's own Valhalla sets its own limits.
*/
export async function valhallaTurn(base: string, signal?: AbortSignal): Promise<void> {
if (base !== FOSSGIS_VALHALLA) return
for (let wait = lastPublicAskAt + CHUNK_PAUSE_MS - Date.now(); wait > 0; wait = lastPublicAskAt + CHUNK_PAUSE_MS - Date.now()) {
await pause(wait, signal)
if (signal?.aborted) return
}
// Taken in the same synchronous step as the check, so two callers woken together
// cannot both see a free turn.
lastPublicAskAt = Date.now()
}
/** Forget the last turn, for tests that start from a quiet host. */
export function resetValhallaTurns(): void {
lastPublicAskAt = 0
}
/**
* Waits out the host's one request a second before the next question is put.
*
@@ -306,6 +335,8 @@ function routeBody(
/** The parsed answer to one POST, or null for every failure. */
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
try {
await valhallaTurn(base, signal)
if (signal?.aborted) return null
const response = await fetch(`${base}/route`, {
method: 'POST',
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a