diff --git a/src/nominatim_api/server/falcon/server.py b/src/nominatim_api/server/falcon/server.py index cf8ecf3d..b2baf15d 100644 --- a/src/nominatim_api/server/falcon/server.py +++ b/src/nominatim_api/server/falcon/server.py @@ -184,6 +184,10 @@ class APIMiddleware: formatter = load_format_dispatcher('v1', self.api.config.project_dir) for name, func in await api_impl.get_routes(self.api): endpoint = EndpointWrapper(name, func, self.api, formatter) + # If func is a LazySearchEndpoint, give it a reference to wrapper + # so it can replace wrapper.func dynamically + if hasattr(func, 'set_wrapper'): + func.set_wrapper(endpoint) self.app.add_route(f"/{name}", endpoint) if legacy_urls: self.app.add_route(f"/{name}.php", endpoint) diff --git a/src/nominatim_api/v1/server_glue.py b/src/nominatim_api/v1/server_glue.py index 7f0634f8..c02a1307 100644 --- a/src/nominatim_api/v1/server_glue.py +++ b/src/nominatim_api/v1/server_glue.py @@ -12,6 +12,7 @@ from typing import Optional, Any, Type, Dict, cast, Sequence, Tuple from functools import reduce import dataclasses from urllib.parse import urlencode +import asyncio import sqlalchemy as sa @@ -124,6 +125,12 @@ def parse_geometry_details(adaptor: ASGIAdaptor, fmt: str) -> Dict[str, Any]: } +def has_search_name(conn: sa.engine.Connection) -> bool: + """ Check if the search_name table exists in the database. + """ + return sa.inspect(conn).has_table('search_name') + + async def status_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any: """ Server glue for /status endpoint. See API docs for details. """ @@ -441,6 +448,61 @@ async def polygons_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any: return build_response(params, params.formatting().format_result(results, fmt, {})) +async def search_unavailable_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any: + """ Server glue for /search endpoint in reverse-only mode. + Returns 404 when search functionality is not available. + """ + params.raise_error('Search not available (reverse-only mode)', 404) + + +class LazySearchEndpoint: + """ + Lazy-loading search endpoint that replaces itself after first successful check. + + - Falcon: EndpointWrapper stores this instance in wrapper.func + On first request, replace wrapper.func directly with real endpoint + + - Starlette: _wrap_endpoint wraps this instance in a callback + store a delegate function and call it on subsequent requests + """ + def __init__(self, api: NominatimAPIAsync, real_endpoint: EndpointFunc): + self.api = api + self.real_endpoint = real_endpoint + self._lock = asyncio.Lock() + self._wrapper: Any = None # Store reference to Falcon's EndpointWrapper + self._delegate: Optional[EndpointFunc] = None + + def set_wrapper(self, wrapper: Any) -> None: + self._wrapper = wrapper + + async def __call__(self, api: NominatimAPIAsync, params: ASGIAdaptor) -> Any: + if self._delegate is None: + async with self._lock: + # Double-check after acquiring lock (thread safety) + if self._delegate is None: + try: + async with api.begin() as conn: + has_table = await conn.connection.run_sync( + has_search_name) + + if has_table: + # For Starlette + self._delegate = self.real_endpoint + # For Falcon + if self._wrapper is not None: + self._wrapper.func = self.real_endpoint + else: + self._delegate = search_unavailable_endpoint + if self._wrapper is not None: + self._wrapper.func = search_unavailable_endpoint + + except (PGCORE_ERROR, sa.exc.OperationalError, OSError): + # No _delegate set, so retry on next request + params.raise_error('Search temporarily unavailable', 503) + + return await self._delegate(api, params) + + async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc]]: routes = [ ('status', status_endpoint), @@ -451,15 +513,13 @@ async def get_routes(api: NominatimAPIAsync) -> Sequence[Tuple[str, EndpointFunc ('polygons', polygons_endpoint), ] - def has_search_name(conn: sa.engine.Connection) -> bool: - insp = sa.inspect(conn) - return insp.has_table('search_name') - try: async with api.begin() as conn: if await conn.connection.run_sync(has_search_name): routes.append(('search', search_endpoint)) - except (PGCORE_ERROR, sa.exc.OperationalError): - pass # ignored + else: + routes.append(('search', search_unavailable_endpoint)) + except (PGCORE_ERROR, sa.exc.OperationalError, OSError): + routes.append(('search', LazySearchEndpoint(api, search_endpoint))) return routes