diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index c4e760f26..64f63f013 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -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() + }) }) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index 57934fc61..d9180a5b7 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -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 { + 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 { + 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 }