Files
routesapi/app/services/__init__.py
Suriya 9ef2f61870 sync: capture the production server's code, which was never committed
/root/Routes-api on 31.97.228.132 is not a git repository. Work had been
done directly on the box and existed nowhere else -- a single rm -rf from
being lost, and impossible to review or roll back.

Deploying the previous HEAD over it would have silently reverted all of
this. Most visibly the Valhalla road-backend probe in main.py, whose own
comment explains why it exists: road sequencing degrades to aerial
silently by design, so an unreachable backend stays invisible, "which is
exactly how the expired Google key went unnoticed". Overwriting it would
have reintroduced precisely the failure it was written to catch, and the
service would have kept answering 200 throughout.

The server had also moved from Google Maps to Valhalla for road distance
(VALHALLA_URL, road_backend_status, +190 lines in route_optimizer),
extended docker-compose from 44 to 95 lines, and changed rider fetching,
health, dynamic config and the cache layer.

Only 10 files differ in substance. The other 27 that appeared to differ
were CRLF-vs-LF noise -- the server writes CRLF -- and are normalised to
LF here rather than committed as spurious whole-file rewrites.

Committed as-is, before any change of mine, so the diff that follows is
reviewable against what is actually running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:56:32 +05:30

202 lines
7.9 KiB
Python

"""Services package."""
from __future__ import annotations
import json
import os
import logging
from typing import Any, Optional, Dict
try:
import redis # type: ignore
except Exception: # pragma: no cover
redis = None # type: ignore
logger = logging.getLogger(__name__)
def _redis_url_from_parts() -> Optional[str]:
"""
Build a redis:// URL from discrete env vars, percent-encoding the credentials.
A hand-written REDIS_URL breaks the moment the password contains a reserved
character: an '@' splits the authority section early, so the client silently
dials a garbage host instead of failing loudly. Assembling the URL here with
quote() removes that whole class of bug - callers set REDIS_HOST/REDIS_PASSWORD
and never have to think about escaping.
Returns None when REDIS_HOST is unset, so an explicit REDIS_URL still wins.
"""
host = os.getenv("REDIS_HOST")
if not host:
return None
from urllib.parse import quote
user = os.getenv("REDIS_USERNAME", "")
pwd = os.getenv("REDIS_PASSWORD", "")
port = os.getenv("REDIS_PORT", "6379")
db = os.getenv("REDIS_DB", "0")
auth = f"{quote(user, safe='')}:{quote(pwd, safe='')}@" if pwd else ""
return f"redis://{auth}{host}:{port}/{db}"
class RedisCache:
"""Lightweight Redis cache wrapper with graceful in-memory fallback."""
def __init__(self, url_env: str = "REDIS_URL", default_ttl_seconds: Optional[int] = None) -> None:
import threading
self._lock = threading.Lock()
self._memory_cache: Dict[str, tuple[float, str]] = {} # key -> (expire_time, serialized_value)
# Allow TTL to be configurable via env var (default 300s = 5 min, or 86400 = 24h)
ttl_env = os.getenv("REDIS_CACHE_TTL_SECONDS")
if default_ttl_seconds is None:
default_ttl_seconds = int(ttl_env) if ttl_env else 300
self.default_ttl_seconds = default_ttl_seconds
self._enabled = False
self._client = None
self._stats = {"hits": 0, "misses": 0, "sets": 0}
url = os.getenv(url_env) or _redis_url_from_parts()
if not url or redis is None:
_why = "redis client not installed" if redis is None else "no REDIS_URL/REDIS_HOST"
logger.warning(f"Redis not configured or client unavailable ({_why}); falling back to local thread-safe in-memory cache")
return
try:
self._client = redis.Redis.from_url(url, decode_responses=True)
self._client.ping()
self._enabled = True
logger.info(f"Redis cache connected (TTL: {self.default_ttl_seconds}s)")
except Exception as exc:
logger.warning(f"Redis connection failed: {exc}; falling back to local thread-safe in-memory cache")
self._enabled = False
self._client = None
@property
def enabled(self) -> bool:
return self._enabled and self._client is not None
def get_json(self, key: str) -> Optional[Any]:
if self.enabled:
try:
raw = self._client.get(key) # type: ignore[union-attr]
if raw:
self._stats["hits"] += 1
return json.loads(raw)
else:
self._stats["misses"] += 1
return None
except Exception as exc:
logger.debug(f"Redis get_json error for key={key}: {exc}")
self._stats["misses"] += 1
return None
else:
import time
with self._lock:
if key in self._memory_cache:
expire_time, raw = self._memory_cache[key]
if expire_time < 0 or expire_time > time.time():
self._stats["hits"] += 1
return json.loads(raw)
else:
del self._memory_cache[key]
self._stats["misses"] += 1
return None
def set_json(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
payload = json.dumps(value, default=lambda o: getattr(o, "model_dump", lambda: o)())
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds
if self.enabled:
try:
if ttl > 0:
self._client.setex(key, ttl, payload) # type: ignore[union-attr]
else:
self._client.set(key, payload) # type: ignore[union-attr]
self._stats["sets"] += 1
except Exception as exc:
logger.debug(f"Redis set_json error for key={key}: {exc}")
else:
import time
expire_time = (time.time() + ttl) if ttl > 0 else -1.0
with self._lock:
# Evict oldest keys if cache grows too large to prevent leak
if len(self._memory_cache) >= 2000:
now = time.time()
expired_keys = [k for k, (exp, _) in self._memory_cache.items() if exp > 0 and exp < now]
for k in expired_keys:
del self._memory_cache[k]
if len(self._memory_cache) >= 2000:
first_key = next(iter(self._memory_cache))
del self._memory_cache[first_key]
self._memory_cache[key] = (expire_time, payload)
self._stats["sets"] += 1
def delete(self, pattern: str) -> int:
"""Delete keys matching pattern (e.g., 'routes:*'). Returns count deleted."""
if self.enabled:
try:
keys = list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr]
if keys:
return self._client.delete(*keys) # type: ignore[union-attr]
return 0
except Exception as exc:
logger.error(f"Redis delete error for pattern={pattern}: {exc}")
return 0
else:
import fnmatch
deleted = 0
with self._lock:
keys_to_del = [k for k in self._memory_cache.keys() if fnmatch.fnmatchcase(k, pattern)]
for k in keys_to_del:
del self._memory_cache[k]
deleted += 1
return deleted
def get_stats(self) -> Dict[str, Any]:
"""Get cache statistics."""
stats = self._stats.copy()
if self.enabled:
try:
# Count cache keys
route_keys = list(self._client.scan_iter(match="routes:*")) # type: ignore[union-attr]
stats["total_keys"] = len(route_keys)
stats["enabled"] = True
stats["type"] = "Redis"
except Exception:
stats["total_keys"] = 0
stats["enabled"] = True
stats["type"] = "Redis"
else:
import time
now = time.time()
with self._lock:
active_keys = [k for k, (exp, _) in self._memory_cache.items() if exp < 0 or exp > now]
stats["total_keys"] = len(active_keys)
stats["enabled"] = True
stats["type"] = "In-Memory Fallback"
return stats
def get_keys(self, pattern: str = "routes:*") -> list[str]:
"""Get list of cache keys matching pattern."""
if self.enabled:
try:
return list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr]
except Exception as exc:
logger.error(f"Redis get_keys error for pattern={pattern}: {exc}")
return []
else:
import fnmatch
import time
now = time.time()
with self._lock:
return [
k for k, (exp, _) in self._memory_cache.items()
if (exp < 0 or exp > now) and fnmatch.fnmatchcase(k, pattern)
]
# Singleton cache instance for app
cache = RedisCache()