Fix planner build: move Overpass upstream fetch to overpass.server.ts
Route files in React Router only get server-code stripped from a specific set of exports (loader/action/middleware/headers). The overpass failover work added a `fetchWithFailover` named export to `app/routes/api.overpass.ts` for unit tests, but that export pulls in `metrics.server` (prom-client registry, etc.) — which then leaks into the client bundle and fails the production build with: [plugin react-router:dot-server] Error: Server-only module referenced by client 'app/lib/metrics.server' imported by route 'app/routes/api.overpass.ts' Move the upstream fetch logic and its config constants into `app/lib/overpass.server.ts`. The `.server.ts` naming makes the server-only boundary explicit, and tests now import from the lib instead of from the route. The route keeps only its `action` export (plus the per-instance LRU cache, which is internal to the module and not exported). Tests: 85 passing (no behavior changes). Build: planner + journal both succeed. Typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b53fb736a4
commit
458b0a8a4c
3 changed files with 116 additions and 114 deletions
111
apps/planner/app/lib/overpass.server.ts
Normal file
111
apps/planner/app/lib/overpass.server.ts
Normal file
|
|
@ -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<UpstreamResult | null> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<UpstreamResult>>();
|
||||
|
||||
|
|
@ -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<UpstreamResult | null> {
|
||||
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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue