Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| Server-side proxy for OpenStreetMap Nominatim geocoding. | |
| Why this exists: the SPA used to call ``nominatim.openstreetmap.org`` directly | |
| from the browser. That fails in two ways: | |
| 1. CORS — Nominatim does not reliably send ``Access-Control-Allow-Origin``, | |
| and the custom ``User-Agent`` the frontend tried to set is a *forbidden | |
| header* the browser silently drops, so requests arrive unidentified. | |
| 2. Policy — Nominatim's usage policy caps clients at ~1 req/s and requires a | |
| real identifying User-Agent / contact. Per-keystroke browser calls trip | |
| the rate limiter, which then returns header-less error pages → the CORS | |
| error the user sees. | |
| Proxying through the API fixes both: it's same-origin (no CORS), sets a proper | |
| identifying User-Agent server-side, throttles to <=1 req/s globally, and caches | |
| recent queries so autocomplete bursts collapse to a single upstream call. | |
| Responses are passed through unchanged so the existing frontend parsing | |
| (``lat``, ``lon``, ``display_name``, ``address``, ``osm_type``, ``osm_id``) | |
| keeps working. | |
| NOTE: for production scale, self-host Nominatim or use a paid geocoder — a | |
| single shared 1 req/s budget is a courtesy limit, not a throughput guarantee. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| import time | |
| from typing import Any, Optional | |
| import httpx | |
| from fastapi import APIRouter, HTTPException, Query | |
| from loguru import logger | |
| router = APIRouter(prefix="/geocode", tags=["geocode"]) | |
| _NOMINATIM_BASE = "https://nominatim.openstreetmap.org" | |
| # Nominatim's policy requires an identifying User-Agent with contact info. | |
| # Override via env in deploys (e.g. "OpenNavigator/1.0 (ops@example.org)"). | |
| _USER_AGENT = os.getenv( | |
| "NOMINATIM_USER_AGENT", | |
| "OpenNavigator/1.0 (https://github.com/getcommunityone/open-navigator)", | |
| ) | |
| # Global throttle: never issue upstream calls faster than this. | |
| _MIN_INTERVAL_S = 1.05 | |
| _throttle_lock = asyncio.Lock() | |
| _last_call_ts = 0.0 | |
| # Tiny in-memory TTL cache. Autocomplete fires several near-identical queries; | |
| # caching collapses them and shields Nominatim. Keyed by the full request path. | |
| _CACHE_TTL_S = 300.0 | |
| _CACHE_MAX = 512 | |
| _cache: dict[str, tuple[float, Any]] = {} | |
| def _cache_get(key: str) -> Optional[Any]: | |
| hit = _cache.get(key) | |
| if not hit: | |
| return None | |
| ts, payload = hit | |
| if time.monotonic() - ts > _CACHE_TTL_S: | |
| _cache.pop(key, None) | |
| return None | |
| return payload | |
| def _cache_put(key: str, payload: Any) -> None: | |
| if len(_cache) >= _CACHE_MAX: | |
| # Cheap eviction: drop the oldest entry. | |
| oldest = min(_cache.items(), key=lambda kv: kv[1][0])[0] | |
| _cache.pop(oldest, None) | |
| _cache[key] = (time.monotonic(), payload) | |
| async def _throttled_get(path: str, params: dict[str, str]) -> Any: | |
| """GET ``{base}{path}`` through the global throttle, with caching + UA.""" | |
| cache_key = f"{path}?{httpx.QueryParams(params)}" | |
| cached = _cache_get(cache_key) | |
| if cached is not None: | |
| return cached | |
| global _last_call_ts | |
| async with _throttle_lock: | |
| wait = _MIN_INTERVAL_S - (time.monotonic() - _last_call_ts) | |
| if wait > 0: | |
| await asyncio.sleep(wait) | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| resp = await client.get( | |
| f"{_NOMINATIM_BASE}{path}", | |
| params=params, | |
| headers={ | |
| "User-Agent": _USER_AGENT, | |
| "Accept": "application/json", | |
| }, | |
| ) | |
| finally: | |
| _last_call_ts = time.monotonic() | |
| if resp.status_code == 429: | |
| raise HTTPException(status_code=429, detail="Geocoder rate-limited; retry shortly.") | |
| if resp.status_code >= 400: | |
| logger.warning("Nominatim {} for {}: {}", resp.status_code, path, resp.text[:200]) | |
| raise HTTPException(status_code=502, detail=f"Geocoder error ({resp.status_code}).") | |
| try: | |
| payload = resp.json() | |
| except ValueError as exc: | |
| raise HTTPException(status_code=502, detail="Geocoder returned non-JSON.") from exc | |
| _cache_put(cache_key, payload) | |
| return payload | |
| async def geocode_search( | |
| q: str = Query(..., min_length=3, description="Free-text address / place query."), | |
| limit: int = Query(6, ge=1, le=20), | |
| countrycodes: str = Query("us", description="Comma-separated ISO country codes to bias to."), | |
| ): | |
| """ | |
| Forward-geocode a free-text query. Returns Nominatim's JSON array verbatim | |
| (each item has ``lat``, ``lon``, ``display_name``, ``address``, | |
| ``osm_type``, ``osm_id`` …). Empty array means no match. | |
| """ | |
| return await _throttled_get( | |
| "/search", | |
| { | |
| "q": q.strip(), | |
| "format": "json", | |
| "addressdetails": "1", | |
| "countrycodes": countrycodes, | |
| "limit": str(limit), | |
| }, | |
| ) | |
| async def geocode_reverse( | |
| lat: float = Query(..., ge=-90, le=90), | |
| lon: float = Query(..., ge=-180, le=180), | |
| ): | |
| """Reverse-geocode a lat/lng to its nearest address (Nominatim JSON object).""" | |
| return await _throttled_get( | |
| "/reverse", | |
| { | |
| "lat": str(lat), | |
| "lon": str(lon), | |
| "format": "json", | |
| "addressdetails": "1", | |
| }, | |
| ) | |