diff --git a/apps/planner/app/lib/overpass.server.ts b/apps/planner/app/lib/overpass.server.ts new file mode 100644 index 0000000..6e78507 --- /dev/null +++ b/apps/planner/app/lib/overpass.server.ts @@ -0,0 +1,111 @@ +import { + overpassUpstreamDuration, + overpassUpstreamRequests, +} from "~/lib/metrics.server"; + +/** + * Ordered list of upstream Overpass endpoints. We try each in turn + * until one returns a usable response; on timeout, network error, + * non-2xx status, or a body carrying Overpass's `rate_limited` runtime + * error, we fall over to the next. The default list keeps us on + * healthy community instances; `OVERPASS_URLS` overrides it and + * `OVERPASS_URL` is kept as a single-entry backward-compat alias. + */ +const DEFAULT_UPSTREAMS = [ + "https://lz4.overpass-api.de/api/interpreter", + "https://overpass-api.de/api/interpreter", +]; + +function loadUpstreams(): string[] { + const list = process.env.OVERPASS_URLS ?? process.env.OVERPASS_URL; + if (!list) return DEFAULT_UPSTREAMS; + return list.split(",").map((s) => s.trim()).filter(Boolean); +} + +export const UPSTREAMS = loadUpstreams(); +const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)"; + +// Per-attempt wall-clock budget. Keep aggressive so failover kicks in +// quickly when an upstream is saturated — a single slow instance +// shouldn't cost the user minutes. +const PER_UPSTREAM_TIMEOUT_MS = 10_000; + +export interface UpstreamResult { + body: string; + contentType: string; + status: number; + cacheable: boolean; +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return "unknown"; + } +} + +/** + * Try each upstream in order until one returns a usable response. + * Returns the first successful result, or `null` if every upstream + * failed. `clientSignal` is the browser's abort signal — if the user + * gives up before we succeed, we stop trying and propagate the abort + * to the in-flight upstream fetch so we don't waste its capacity on a + * request nobody wants anymore. + */ +export async function fetchWithFailover( + body: string, + clientSignal: AbortSignal, + urls: readonly string[] = UPSTREAMS, +): Promise { + for (const url of urls) { + if (clientSignal.aborted) break; + const upstream = hostOf(url); + const start = Date.now(); + try { + const signal = AbortSignal.any([ + clientSignal, + AbortSignal.timeout(PER_UPSTREAM_TIMEOUT_MS), + ]); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + }, + body, + signal, + }); + const responseBody = await response.text(); + overpassUpstreamDuration.observe({ upstream }, (Date.now() - start) / 1000); + overpassUpstreamRequests.inc({ upstream, status: String(response.status) }); + + // Overpass returns 200 with a `rate_limited` runtime error when + // the instance is throttling us. Treat that like a 5xx: try the + // next upstream. + if (!response.ok || responseBody.includes("rate_limited")) { + continue; + } + + return { + body: responseBody, + contentType: response.headers.get("content-type") ?? "application/json", + status: 200, + cacheable: true, + }; + } catch (err) { + overpassUpstreamDuration.observe({ upstream }, (Date.now() - start) / 1000); + // If the CLIENT aborted, stop the whole loop — the user doesn't + // want an answer anymore. Any other error (timeout, network, + // DNS) counts as "this upstream failed, try the next." + if (clientSignal.aborted) { + overpassUpstreamRequests.inc({ upstream, status: "client-abort" }); + throw err; + } + const label = (err as Error).name === "TimeoutError" ? "timeout" : "error"; + overpassUpstreamRequests.inc({ upstream, status: label }); + continue; + } + } + return null; +} diff --git a/apps/planner/app/routes/api.overpass.test.ts b/apps/planner/app/routes/api.overpass.test.ts index 2d4a6e0..65212b5 100644 --- a/apps/planner/app/routes/api.overpass.test.ts +++ b/apps/planner/app/routes/api.overpass.test.ts @@ -10,7 +10,7 @@ vi.mock("~/lib/metrics.server", () => ({ overpassUpstreamRequests: { inc: vi.fn() }, })); -import { fetchWithFailover } from "./api.overpass"; +import { fetchWithFailover } from "~/lib/overpass.server"; import { overpassUpstreamRequests, overpassUpstreamDuration, diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts index fda4e83..64d3226 100644 --- a/apps/planner/app/routes/api.overpass.ts +++ b/apps/planner/app/routes/api.overpass.ts @@ -1,39 +1,11 @@ import type { Route } from "./+types/api.overpass"; import { checkRateLimit } from "~/lib/rate-limit"; import { requireSession } from "~/lib/require-session"; +import { overpassCacheEvents, overpassCacheSize } from "~/lib/metrics.server"; import { - overpassCacheEvents, - overpassCacheSize, - overpassUpstreamDuration, - overpassUpstreamRequests, -} from "~/lib/metrics.server"; - -/** - * Ordered list of upstream Overpass endpoints. We try each in turn - * until one returns a usable response; on timeout, network error, - * non-2xx status, or a body carrying Overpass's `rate_limited` runtime - * error, we fall over to the next. The default list keeps us on - * healthy community instances; `OVERPASS_URLS` overrides it and - * `OVERPASS_URL` is kept as a single-entry backward-compat alias. - */ -const DEFAULT_UPSTREAMS = [ - "https://lz4.overpass-api.de/api/interpreter", - "https://overpass-api.de/api/interpreter", -]; - -function loadUpstreams(): string[] { - const list = process.env.OVERPASS_URLS ?? process.env.OVERPASS_URL; - if (!list) return DEFAULT_UPSTREAMS; - return list.split(",").map((s) => s.trim()).filter(Boolean); -} - -const UPSTREAMS = loadUpstreams(); -const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)"; - -// Per-attempt wall-clock budget. Keep aggressive so failover kicks in -// quickly when an upstream is saturated — a single slow instance -// shouldn't cost the user minutes. -const PER_UPSTREAM_TIMEOUT_MS = 10_000; + fetchWithFailover, + type UpstreamResult, +} from "~/lib/overpass.server"; const CACHE_TTL_MS = 10 * 60 * 1000; const CACHE_MAX_ENTRIES = 200; @@ -44,13 +16,6 @@ interface CacheEntry { expiresAt: number; } -interface UpstreamResult { - body: string; - contentType: string; - status: number; - cacheable: boolean; -} - const cache = new Map(); const inFlight = new Map>(); @@ -77,80 +42,6 @@ function putInCache(key: string, body: string, contentType: string) { overpassCacheSize.set(cache.size); } -function hostOf(url: string): string { - try { - return new URL(url).hostname; - } catch { - return "unknown"; - } -} - -/** - * Try each upstream in order until one returns a usable response. - * Returns the first successful result, or `null` if every upstream - * failed. `clientSignal` is the browser's abort signal — if the user - * gives up before we succeed, we stop trying and propagate the abort - * to the in-flight upstream fetch so we don't waste its capacity on a - * request nobody wants anymore. Exported for tests; the action - * handler calls it with the module-level `UPSTREAMS` list. - */ -export async function fetchWithFailover( - body: string, - clientSignal: AbortSignal, - urls: readonly string[] = UPSTREAMS, -): Promise { - for (const url of urls) { - if (clientSignal.aborted) break; - const upstream = hostOf(url); - const start = Date.now(); - try { - const signal = AbortSignal.any([ - clientSignal, - AbortSignal.timeout(PER_UPSTREAM_TIMEOUT_MS), - ]); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": USER_AGENT, - }, - body, - signal, - }); - const responseBody = await response.text(); - overpassUpstreamDuration.observe({ upstream }, (Date.now() - start) / 1000); - overpassUpstreamRequests.inc({ upstream, status: String(response.status) }); - - // Overpass returns 200 with a `rate_limited` runtime error when - // the instance is throttling us. Treat that like a 5xx: try the - // next upstream. - if (!response.ok || responseBody.includes("rate_limited")) { - continue; - } - - return { - body: responseBody, - contentType: response.headers.get("content-type") ?? "application/json", - status: 200, - cacheable: true, - }; - } catch (err) { - overpassUpstreamDuration.observe({ upstream }, (Date.now() - start) / 1000); - // If the CLIENT aborted, stop the whole loop — the user doesn't - // want an answer anymore. Any other error (timeout, network, - // DNS) counts as "this upstream failed, try the next." - if (clientSignal.aborted) { - overpassUpstreamRequests.inc({ upstream, status: "client-abort" }); - throw err; - } - const label = (err as Error).name === "TimeoutError" ? "timeout" : "error"; - overpassUpstreamRequests.inc({ upstream, status: label }); - continue; - } - } - return null; -} - export async function action({ request }: Route.ActionArgs) { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 });