fix(client): ask FOSSGIS when the routing relay times out, refuses the body or is missing

A route or height question through the relay now gives up after 12 s. A 400 without Valhalla's error_code and a 404 or 405 count as the relay unable to answer instead of no way or no Valhalla, and a Retry-After sets how long the tab leaves the relay alone.
This commit is contained in:
Maurice
2026-10-29 11:05:44 +01:00
committed by Maurice
parent 686592437a
commit 45727b3d3b
2 changed files with 93 additions and 8 deletions
@@ -793,4 +793,56 @@ describe('through the TREK services (routing relay)', () => {
expect(bodies[0]).not.toHaveProperty('shape')
expect(bodies[0]).toMatchObject({ encoded_polyline: expect.any(String), costing: 'bicycle', shape_match: 'map_snap' })
})
it('VALHALLA-RELAY-006: reads a 400 without error_code, and a route the server does not have, as the relay unable to answer', async () => {
const refusals = [
() => HttpResponse.json({ error: 'Expected number, received string' }, { status: 400 }),
() => new HttpResponse('Not Found', { status: 404 }),
() => HttpResponse.json({ error: 'Method Not Allowed' }, { status: 405 }),
]
for (const refusal of refusals) {
resetValhallaTurns()
let fossgis = 0
server.use(
http.post(RELAY, refusal),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
expect(fossgis).toBe(1)
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
}
})
it('VALHALLA-RELAY-007: rests the relay as long as its Retry-After says, at most an hour', async () => {
for (const [retryAfter, restMs] of [['30', 30_000], ['86400', 60 * 60_000]] as const) {
resetValhallaTurns()
server.use(
http.post(RELAY, () => HttpResponse.json({ error: 'spent' }, { status: 429, headers: { 'Retry-After': retryAfter } })),
http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(answer())),
)
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(Date.now() + restMs - 1000)
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
vi.setSystemTime(Date.now() + 2000)
expect(valhallaBase()).toBe(TREK_VALHALLA)
vi.useRealTimers()
}
})
it('VALHALLA-RELAY-008: gives up on a relay that does not answer in time and asks FOSSGIS', async () => {
const timeout = AbortSignal.timeout.bind(AbortSignal)
const spy = vi.spyOn(AbortSignal, 'timeout').mockImplementation(ms =>
ms === 12_000 ? AbortSignal.abort(new DOMException('signal timed out', 'TimeoutError')) : timeout(ms))
let fossgis = 0
server.use(
http.post(RELAY, () => HttpResponse.json(answer())),
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
)
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
expect(spy).toHaveBeenCalledWith(12_000)
expect(fossgis).toBe(1)
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
spy.mockRestore()
})
})
+41 -8
View File
@@ -52,6 +52,16 @@ const TREK_VALHALLA = '/api/routing/valhalla'
*/
const RELAY_REST_MS = 10 * 60_000
/** The longest Retry-After of the relay this tab follows; a longer one is asked about again after it. */
const RELAY_MAX_WAIT_MS = 60 * 60_000
/**
* How long a route or a height question may take through the relay. The server gives the
* TREK Valhalla ten seconds; this leaves room for its 502 to arrive first, and where the
* relay itself hangs, FOSSGIS answers soon after.
*/
const RELAY_TIMEOUT_MS = 12_000
/**
* How far Valhalla may look for a road under a point, in metres.
*
@@ -388,22 +398,45 @@ export function resetValhallaTurns(): void {
}
/**
* The relay's statuses that say it cannot answer for the TREK services right now: no
* session (a shared page), no key, a body it does not take, an allowance spent, a host
* that is down. A 400 is Valhalla's own refusal of the way and is read as such.
* Whether the relay's answer says it cannot answer for the TREK services right now: no
* session (a shared page), no key, a body it does not take, a route this server does not
* have yet, an allowance spent, a host that is down. A 400 that carries Valhalla's
* `error_code` is its own refusal of the way and is read as such; one without is the
* relay refusing the body.
*/
function relayCannotAnswer(status: number): boolean {
return status === 401 || status === 403 || status === 413 || status === 429 || status >= 500
async function relayCannotAnswer(response: Response): Promise<boolean> {
if (response.status === 400) {
const data: unknown = await response.clone().json().catch(() => null)
return typeof (data as { error_code?: unknown } | null)?.error_code !== 'number'
}
return [401, 403, 404, 405, 413, 429].includes(response.status) || response.status >= 500
}
/** The relay's Retry-After in milliseconds, as seconds or an HTTP date, or null without one. */
function relayWait(response: Response): number | null {
const value = response.headers.get('Retry-After')?.trim()
if (!value) return null
if (/^\d+$/.test(value)) return Number(value) * 1000
const at = Date.parse(value)
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
async function askRelay(action: ValhallaRelayAction, body: object, signal?: AbortSignal): Promise<Response | null> {
let bounded = signal
// A description brings its own deadline; a route or a height gets the relay's.
if (action !== 'trace_attributes') {
const deadline = AbortSignal.timeout(RELAY_TIMEOUT_MS)
bounded = signal ? AbortSignal.any([signal, deadline]) : deadline
}
let wait = RELAY_REST_MS
try {
const response = await valhallaRelayRepo.ask(action, body, signal)
if (!relayCannotAnswer(response.status)) return response
const response = await valhallaRelayRepo.ask(action, body, bounded)
if (!(await relayCannotAnswer(response))) return response
wait = Math.min(relayWait(response) ?? RELAY_REST_MS, RELAY_MAX_WAIT_MS)
} catch (err) {
if (signal?.aborted) throw err
}
relayRestingUntil = Date.now() + RELAY_REST_MS
relayRestingUntil = Date.now() + wait
return null
}