| """ |
| Mauritius Bus Route Planner — tiny stdlib HTTP server. |
| |
| Serves the static frontend and a small JSON API backed by busapi.py |
| (journey planning via mauritius-buses.com) and geocode.py (stop -> coordinates |
| via OpenStreetMap / Nominatim). |
| |
| python3 server.py # http://localhost:8000 |
| PORT=9000 python3 server.py |
| |
| Endpoints: |
| GET /api/stops -> [{id, name}, ...] |
| GET /api/plan?day=&from=&to= -> itinerary with coords attached |
| """ |
|
|
| import hashlib |
| import json |
| import math |
| import os |
| import sys |
| import threading |
| import time |
| import traceback |
| import warnings |
| import gzip as _gzip |
| from email.utils import formatdate |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| from urllib.parse import urlparse, parse_qs |
|
|
| |
| |
| |
| GZIP_EXTS = {".html", ".js", ".css", ".json", ".svg"} |
|
|
| |
| warnings.filterwarnings("ignore", message=".*OpenSSL.*") |
|
|
| import busapi |
| import events as events_mod |
| import geocode |
| import nearby |
| import roads |
|
|
| try: |
| import psutil |
|
|
| _PROC = psutil.Process() |
| _PROC.cpu_percent(interval=None) |
| psutil.cpu_percent(interval=None) |
| except Exception: |
| psutil = None |
| _PROC = None |
|
|
| ROOT = os.path.dirname(os.path.abspath(__file__)) |
| PUBLIC = os.path.join(ROOT, "public") |
| PORT = int(os.environ.get("PORT", "8000")) |
|
|
| |
| |
| EVENTS_MIN_HEALTHY = int(os.environ.get("EVENTS_MIN_HEALTHY", "5")) |
|
|
| |
| |
| |
| |
| |
| _CLIENT_ERR_MAX_BYTES = 4096 |
| _CLIENT_ERR_WINDOW = 60.0 |
| _CLIENT_ERR_MAX_PER_WINDOW = 30 |
| _client_err_hits = [] |
|
|
| |
| |
| |
| |
| RATE_RPS = float(os.environ.get("API_RATE_RPS", "5")) |
| RATE_BURST = float(os.environ.get("API_RATE_BURST", "40")) |
|
|
|
|
| class _RateLimiter: |
| """Token bucket keyed by client IP. Thread-safe (ThreadingHTTPServer), and |
| self-pruning so a flood of unique IPs can't grow the map without bound.""" |
|
|
| def __init__(self, rps, burst): |
| self.rps = rps |
| self.burst = burst |
| self._buckets = {} |
| self._lock = threading.Lock() |
|
|
| def allow(self, ip): |
| now = time.monotonic() |
| with self._lock: |
| tokens, last = self._buckets.get(ip, (self.burst, now)) |
| tokens = min(self.burst, tokens + (now - last) * self.rps) |
| ok = tokens >= 1.0 |
| self._buckets[ip] = (tokens - 1.0 if ok else tokens, now) |
| if len(self._buckets) > 4096: |
| self._buckets = { |
| k: v |
| for k, v in self._buckets.items() |
| if v[0] < self.burst or (now - v[1]) < 120 |
| } |
| return ok |
|
|
|
|
| RATE_LIMITER = _RateLimiter(RATE_RPS, RATE_BURST) |
|
|
| |
| |
| |
| _UPSTREAM_HOSTS = { |
| "overpass-api.de": "overpass", |
| "nominatim.openstreetmap.org": "nominatim", |
| "router.project-osrm.org": "osrm", |
| "routing.openstreetmap.de": "osrm", |
| "partyapp.mu": "events_partyapp", |
| "www.partyapp.mu": "events_partyapp", |
| "otayo.com": "events_otayo", |
| "www.otayo.com": "events_otayo", |
| } |
|
|
|
|
| def _upstream_label(url): |
| try: |
| return _UPSTREAM_HOSTS.get(urlparse(url).hostname or "", "other") |
| except Exception: |
| return "other" |
|
|
|
|
| def _instrument_requests(): |
| """Wrap requests.get/post once to time external calls and record per-upstream |
| success/latency in METRICS — without touching the worker modules (they call |
| requests.get/post by attribute, so this is picked up transparently). A |
| timeout/connection error counts as an error and is re-raised unchanged.""" |
| try: |
| import requests as _rq |
| except Exception: |
| return |
| for name in ("get", "post"): |
| orig = getattr(_rq, name, None) |
| if orig is None or getattr(orig, "_mobaz_wrapped", False): |
| continue |
|
|
| def make(orig): |
| def wrapper(url, *a, **kw): |
| dep = _upstream_label(url if isinstance(url, str) else "") |
| t0 = time.perf_counter() |
| ok = False |
| try: |
| resp = orig(url, *a, **kw) |
| ok = getattr(resp, "ok", True) |
| return resp |
| finally: |
| METRICS.observe_upstream(dep, time.perf_counter() - t0, ok) |
|
|
| wrapper._mobaz_wrapped = True |
| return wrapper |
|
|
| setattr(_rq, name, make(orig)) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _LATENCY_BUCKETS = (0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10) |
| _KNOWN_ROUTES = { |
| "/api/plan", |
| "/api/stops", |
| "/api/nearby", |
| "/api/geocode", |
| "/api/walk", |
| "/api/events", |
| "/api/health", |
| "/api/clienterror", |
| "/metrics", |
| "/status", |
| } |
|
|
|
|
| def _route_label(path): |
| """Collapse a request path to a low-cardinality route label.""" |
| if path in _KNOWN_ROUTES: |
| return path |
| if path.startswith("/api/"): |
| return "/api/other" |
| if path.startswith("/data/"): |
| return "/data" |
| if path in ("/", "", "/index.html"): |
| return "/" |
| return "/static" |
|
|
|
|
| class _Metrics: |
| def __init__(self): |
| self._lock = threading.Lock() |
| self.start = time.time() |
| self.requests = {} |
| self.bucket_counts = [0] * len(_LATENCY_BUCKETS) |
| self.duration_sum = 0.0 |
| self.duration_count = 0 |
| self.client_errors = 0 |
| self.beacons_dropped = 0 |
| self.inflight = 0 |
| self.clients = [] |
| self.plan_results = {} |
| self.upstream = {} |
|
|
| def observe(self, route, method, code, dur): |
| with self._lock: |
| key = (route, method, int(code)) |
| self.requests[key] = self.requests.get(key, 0) + 1 |
| self.duration_sum += dur |
| self.duration_count += 1 |
| for i, le in enumerate(_LATENCY_BUCKETS): |
| if dur <= le: |
| self.bucket_counts[i] += 1 |
|
|
| def observe_plan(self, outcome): |
| """Count journey-plan outcomes so 'no route found' is visible (a data |
| gap), distinct from a successful plan or a server error.""" |
| with self._lock: |
| self.plan_results[outcome] = self.plan_results.get(outcome, 0) + 1 |
|
|
| def observe_upstream(self, dep, dur, ok): |
| """Record one external dependency call (Overpass/Nominatim/OSRM/scrape): |
| count, error count, and total time so the dashboard can show per-upstream |
| error rate and average latency — the usual root cause of user-facing |
| failures, otherwise buried inside route latency.""" |
| with self._lock: |
| s = self.upstream.get(dep) |
| if s is None: |
| s = self.upstream[dep] = {"ok": 0, "err": 0, "sum": 0.0} |
| s["ok" if ok else "err"] += 1 |
| s["sum"] += dur |
|
|
| def inc_client_error(self, dropped=False): |
| with self._lock: |
| if dropped: |
| self.beacons_dropped += 1 |
| else: |
| self.client_errors += 1 |
|
|
| def inflight_inc(self): |
| with self._lock: |
| self.inflight += 1 |
|
|
| def inflight_dec(self): |
| with self._lock: |
| self.inflight = max(0, self.inflight - 1) |
|
|
| def record_client(self, ip): |
| now = time.time() |
| with self._lock: |
| self.clients.append((now, ip)) |
| if len(self.clients) > 5000: |
| self.clients = self.clients[-5000:] |
|
|
| def active_clients(self, window=300): |
| """Unique client IPs seen in the last `window` seconds (a stateless API |
| has no real 'connections', so this is the meaningful 'active clients').""" |
| cutoff = time.time() - window |
| with self._lock: |
| self.clients = [(t, ip) for (t, ip) in self.clients if t >= cutoff] |
| return len({ip for _, ip in self.clients}) |
|
|
| def _system_metrics(self): |
| """Process + host CPU/RAM via psutil (best-effort; empty if unavailable).""" |
| out = {} |
| if psutil is None or _PROC is None: |
| return out |
| try: |
| out["mobaz_process_cpu_percent"] = float(_PROC.cpu_percent(interval=None)) |
| out["mobaz_process_resident_memory_bytes"] = int(_PROC.memory_info().rss) |
| out["mobaz_process_threads"] = int(_PROC.num_threads()) |
| try: |
| out["mobaz_process_open_fds"] = int(_PROC.num_fds()) |
| except Exception: |
| pass |
| out["mobaz_system_cpu_percent"] = float(psutil.cpu_percent(interval=None)) |
| vm = psutil.virtual_memory() |
| out["mobaz_system_memory_used_percent"] = float(vm.percent) |
| out["mobaz_system_memory_total_bytes"] = int(vm.total) |
| try: |
| out["mobaz_system_load1"] = float(os.getloadavg()[0]) |
| except Exception: |
| pass |
| except Exception: |
| pass |
| return out |
|
|
| def _gauges(self): |
| """Best-effort point-in-time gauges (never raise into /metrics).""" |
| out = {} |
| try: |
| out["mobaz_stops"] = len(busapi.get_stops()) |
| except Exception: |
| out["mobaz_stops"] = 0 |
| try: |
| _ev = events_mod.health() |
| out["mobaz_events_cached"] = _ev.get("count", 0) |
| |
| |
| |
| _age = _ev.get("age_seconds") |
| out["mobaz_events_age_seconds"] = int(_age) if _age is not None else -1 |
| except Exception: |
| out["mobaz_events_cached"] = 0 |
| try: |
| out["mobaz_geocode_cache_entries"] = len(geocode._load_coords_cache()) |
| except Exception: |
| out["mobaz_geocode_cache_entries"] = 0 |
| try: |
| with nearby._cache_lock: |
| out["mobaz_nearby_cache_entries"] = len(nearby._cache) |
| except Exception: |
| out["mobaz_nearby_cache_entries"] = 0 |
| return out |
|
|
| def render(self): |
| """Prometheus text exposition (version 0.0.4).""" |
| with self._lock: |
| requests = dict(self.requests) |
| buckets = list(self.bucket_counts) |
| dur_sum, dur_count = self.duration_sum, self.duration_count |
| client_errors, dropped = self.client_errors, self.beacons_dropped |
| inflight = self.inflight |
| uptime = time.time() - self.start |
| plan_results = dict(self.plan_results) |
| upstream = {k: dict(v) for k, v in self.upstream.items()} |
| L = [] |
| L.append("# HELP mobaz_up Whether the MoBaz backend is serving (always 1).") |
| L.append("# TYPE mobaz_up gauge") |
| L.append("mobaz_up 1") |
| L.append("# HELP mobaz_uptime_seconds Seconds since the backend started.") |
| L.append("# TYPE mobaz_uptime_seconds gauge") |
| L.append("mobaz_uptime_seconds %.0f" % uptime) |
| L.append( |
| "# HELP mobaz_http_requests_total HTTP requests by route, method and code." |
| ) |
| L.append("# TYPE mobaz_http_requests_total counter") |
| for (route, method, code), n in sorted(requests.items()): |
| L.append( |
| 'mobaz_http_requests_total{route="%s",method="%s",code="%d"} %d' |
| % (route, method, code, n) |
| ) |
| L.append( |
| "# HELP mobaz_http_request_duration_seconds Request latency histogram." |
| ) |
| L.append("# TYPE mobaz_http_request_duration_seconds histogram") |
| cumulative = 0 |
| for i, le in enumerate(_LATENCY_BUCKETS): |
| cumulative = buckets[i] |
| L.append( |
| 'mobaz_http_request_duration_seconds_bucket{le="%s"} %d' |
| % (le, cumulative) |
| ) |
| L.append('mobaz_http_request_duration_seconds_bucket{le="+Inf"} %d' % dur_count) |
| L.append("mobaz_http_request_duration_seconds_sum %.6f" % dur_sum) |
| L.append("mobaz_http_request_duration_seconds_count %d" % dur_count) |
| L.append( |
| "# HELP mobaz_client_errors_total Client-side JS errors reported via the beacon." |
| ) |
| L.append("# TYPE mobaz_client_errors_total counter") |
| L.append("mobaz_client_errors_total %d" % client_errors) |
| L.append( |
| "# HELP mobaz_client_error_beacons_dropped_total Beacons dropped by the rate limit." |
| ) |
| L.append("# TYPE mobaz_client_error_beacons_dropped_total counter") |
| L.append("mobaz_client_error_beacons_dropped_total %d" % dropped) |
| L.append("# HELP mobaz_inflight_requests Requests currently being served.") |
| L.append("# TYPE mobaz_inflight_requests gauge") |
| L.append("mobaz_inflight_requests %d" % inflight) |
| L.append( |
| "# HELP mobaz_active_clients Unique client IPs seen in the last 5 minutes." |
| ) |
| L.append("# TYPE mobaz_active_clients gauge") |
| L.append("mobaz_active_clients %d" % self.active_clients()) |
| |
| L.append( |
| "# HELP mobaz_plan_results_total Journey-plan outcomes by result type." |
| ) |
| L.append("# TYPE mobaz_plan_results_total counter") |
| for outcome, n in sorted(plan_results.items()): |
| L.append('mobaz_plan_results_total{result="%s"} %d' % (outcome, n)) |
| |
| L.append( |
| "# HELP mobaz_upstream_requests_total External calls by dependency and outcome." |
| ) |
| L.append("# TYPE mobaz_upstream_requests_total counter") |
| for dep, s in sorted(upstream.items()): |
| L.append( |
| 'mobaz_upstream_requests_total{dep="%s",outcome="ok"} %d' |
| % (dep, s["ok"]) |
| ) |
| L.append( |
| 'mobaz_upstream_requests_total{dep="%s",outcome="error"} %d' |
| % (dep, s["err"]) |
| ) |
| L.append( |
| "# HELP mobaz_upstream_request_duration_seconds_sum Total time in external calls." |
| ) |
| L.append("# TYPE mobaz_upstream_request_duration_seconds_sum counter") |
| for dep, s in sorted(upstream.items()): |
| L.append( |
| 'mobaz_upstream_request_duration_seconds_sum{dep="%s"} %.6f' |
| % (dep, s["sum"]) |
| ) |
| |
| allg = {} |
| allg.update(self._gauges()) |
| allg.update(self._system_metrics()) |
| for name, val in allg.items(): |
| L.append("# TYPE %s gauge" % name) |
| L.append(("%s %d" if isinstance(val, int) else "%s %.4f") % (name, val)) |
| return "\n".join(L) + "\n" |
|
|
|
|
| METRICS = _Metrics() |
| _instrument_requests() |
|
|
|
|
| def _status_html(): |
| """A dependency-free server-rendered metrics summary. Grafana (at /) is the |
| rich view; this stays up even if Grafana is down and needs no datasource.""" |
| with METRICS._lock: |
| reqs = dict(METRICS.requests) |
| uptime = time.time() - METRICS.start |
| dur_sum, dur_count = METRICS.duration_sum, METRICS.duration_count |
| client_errors = METRICS.client_errors |
| gauges = METRICS._gauges() |
| total = sum(reqs.values()) |
| avg_ms = (dur_sum / dur_count * 1000) if dur_count else 0.0 |
| active = METRICS.active_clients() |
| inflight = METRICS.inflight |
| |
| |
| rss_mb = 0 |
| if _PROC is not None: |
| try: |
| rss_mb = _PROC.memory_info().rss / (1024 * 1024) |
| except Exception: |
| rss_mb = 0 |
| |
| by_route = {} |
| for (route, _m, _c), n in reqs.items(): |
| by_route[route] = by_route.get(route, 0) + n |
| rows = ( |
| "".join( |
| "<tr><td>%s</td><td>%d</td></tr>" % (r, n) |
| for r, n in sorted(by_route.items(), key=lambda kv: -kv[1]) |
| ) |
| or "<tr><td colspan=2>no requests yet</td></tr>" |
| ) |
| cards = "".join( |
| "<div class=card><div class=k>%s</div><div class=v>%s</div></div>" % (k, v) |
| for k, v in [ |
| ("uptime", "%dh %dm" % (uptime // 3600, (uptime % 3600) // 60)), |
| ("requests", total), |
| ("avg latency", "%.1f ms" % avg_ms), |
| ("active clients (5m)", active), |
| ("in-flight", inflight), |
| ("memory", "%.0f MB" % rss_mb), |
| ("stops", gauges.get("mobaz_stops", 0)), |
| ("events cached", gauges.get("mobaz_events_cached", 0)), |
| ("geocode cache", gauges.get("mobaz_geocode_cache_entries", 0)), |
| ("nearby cache", gauges.get("mobaz_nearby_cache_entries", 0)), |
| ("client errors", client_errors), |
| ] |
| ) |
| return ( |
| "<!doctype html><meta charset=utf-8><title>MoBaz · status</title>" |
| "<meta name=viewport content='width=device-width,initial-scale=1'>" |
| "<style>body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#0b1020;color:#e7ecf5;padding:24px}" |
| "h1{font-size:20px;margin:0 0 4px}.sub{color:#9fb0cc;margin:0 0 20px}" |
| ".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:24px}" |
| ".card{background:#161d33;border:1px solid #243056;border-radius:12px;padding:14px}" |
| ".k{color:#9fb0cc;font-size:12px;text-transform:uppercase;letter-spacing:.04em}" |
| ".v{font-size:22px;font-weight:700;margin-top:4px}" |
| "table{border-collapse:collapse;width:100%;max-width:480px;background:#161d33;border-radius:12px;overflow:hidden}" |
| "td{padding:8px 14px;border-top:1px solid #243056}tr:first-child td{border-top:0}" |
| "a{color:#7aa2ff}.links{margin-top:20px}</style>" |
| "<h1>MoBaz · backend status</h1>" |
| "<p class=sub>Live counters from the running Space. Full dashboards in " |
| "<a href='/'>Grafana</a>.</p>" |
| "<div class=grid>" + cards + "</div>" |
| "<h2 style='font-size:15px'>Requests by route</h2>" |
| "<table>" + rows + "</table>" |
| "<p class=links><a href='/metrics'>raw /metrics</a> · " |
| "<a href='/api/health'>/api/health</a> · <a href='/app'>open the app</a></p>" |
| ) |
|
|
|
|
| CONTENT_TYPES = { |
| ".html": "text/html; charset=utf-8", |
| ".js": "application/javascript; charset=utf-8", |
| ".css": "text/css; charset=utf-8", |
| ".json": "application/json; charset=utf-8", |
| ".svg": "image/svg+xml", |
| ".png": "image/png", |
| ".ico": "image/x-icon", |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| DETOUR_KM = 9.0 |
| ENDPOINT_KM = 13.0 |
|
|
|
|
| def _km(a, b): |
| return math.hypot((a[0] - b[0]) * 111.0, (a[1] - b[1]) * 105.0) |
|
|
|
|
| def _despike(stops): |
| """Remove (null) coordinates that are clearly mis-geocoded for this leg.""" |
| |
| while True: |
| idx = [i for i, s in enumerate(stops) if s.get("coord")] |
| if len(idx) < 3: |
| break |
| |
| |
| nodes = [] |
| for k, i in enumerate(idx): |
| c = stops[i]["coord"] |
| if nodes and _km(nodes[-1][1], c) < 0.4: |
| nodes[-1][0].append(k) |
| else: |
| nodes.append([[k], c]) |
| if len(nodes) < 3: |
| break |
| worst = None |
| for n in range(1, len(nodes) - 1): |
| p, c, q = nodes[n - 1][1], nodes[n][1], nodes[n + 1][1] |
| detour = _km(p, c) + _km(c, q) - _km(p, q) |
| if detour > DETOUR_KM and (worst is None or detour > worst[0]): |
| worst = (detour, n) |
| if not worst: |
| break |
| for k in nodes[worst[1]][0]: |
| stops[idx[k]]["coord"] = None |
|
|
| |
| |
| idx = [i for i, s in enumerate(stops) if s.get("coord")] |
| if ( |
| len(idx) >= 2 |
| and _km(stops[idx[0]]["coord"], stops[idx[1]]["coord"]) > ENDPOINT_KM |
| ): |
| stops[idx[0]]["coord"] = None |
| idx = [i for i, s in enumerate(stops) if s.get("coord")] |
| if ( |
| len(idx) >= 2 |
| and _km(stops[idx[-1]]["coord"], stops[idx[-2]]["coord"]) > ENDPOINT_KM |
| ): |
| stops[idx[-1]]["coord"] = None |
|
|
|
|
| def _attach_coords(result): |
| """Resolve every stop name in the itinerary to [lat, lon] in place.""" |
| names = [] |
| for opt in result.get("options", []): |
| for leg in opt["legs"]: |
| if leg["type"] == "bus": |
| names += [s["name"] for s in leg["stops"]] |
| elif leg.get("to"): |
| names.append(leg["to"]) |
| coords = geocode.resolve_many(names) |
| for opt in result.get("options", []): |
| for leg in opt["legs"]: |
| if leg["type"] == "bus": |
| for s in leg["stops"]: |
| s["coord"] = coords.get(s["name"]) |
| _despike(leg["stops"]) |
| pts = [s["coord"] for s in leg["stops"] if s["coord"]] |
| leg["fromCoord"] = pts[0] if pts else None |
| leg["toCoord"] = pts[-1] if pts else None |
| |
| leg["geometry"] = roads.snap_route(pts) |
| else: |
| leg["toCoord"] = coords.get(leg.get("to")) |
| return result |
|
|
|
|
| class Handler(BaseHTTPRequestHandler): |
| server_version = "MauritiusBusPlanner/1.0" |
|
|
| def log_message(self, fmt, *args): |
| sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) |
|
|
| |
| |
| |
| |
| def send_response(self, code, message=None): |
| super().send_response(code, message) |
| if not getattr(self, "_recorded", False): |
| self._recorded = True |
| t0 = getattr(self, "_t0", None) |
| if t0 is not None: |
| route = getattr(self, "_route", _route_label(urlparse(self.path).path)) |
| METRICS.observe( |
| route, self.command or "GET", code, time.perf_counter() - t0 |
| ) |
| METRICS.inflight_dec() |
|
|
| def _begin(self, path): |
| """Stamp route/timer, count the request as in-flight, and note the client |
| IP (behind nginx the real IP is in X-Forwarded-For).""" |
| self._t0 = time.perf_counter() |
| self._route = _route_label(path) |
| METRICS.inflight_inc() |
| xff = self.headers.get("X-Forwarded-For") |
| ip = ( |
| xff.split(",")[0].strip() |
| if xff |
| else (self.client_address[0] if self.client_address else "?") |
| ) |
| self._client_ip = ip |
| METRICS.record_client(ip) |
|
|
| def _cors(self): |
| |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") |
|
|
| def do_OPTIONS(self): |
| self.send_response(204) |
| self._cors() |
| self.send_header("Content-Length", "0") |
| self.end_headers() |
|
|
| def _send_json(self, obj, status=200): |
| body = json.dumps(obj, ensure_ascii=False).encode("utf-8") |
| gzipped = False |
| if self._accepts_gzip() and len(body) > 1024: |
| body = _gzip.compress(body, 6) |
| gzipped = True |
| self.send_response(status) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| if gzipped: |
| self.send_header("Content-Encoding", "gzip") |
| self.send_header("Vary", "Accept-Encoding") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("Cache-Control", "no-store") |
| self._cors() |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def _etag_matches(self, etag): |
| """True if the client's If-None-Match lists `etag` (so we can 304).""" |
| inm = self.headers.get("If-None-Match") |
| return bool(inm) and etag in [t.strip() for t in inm.split(",")] |
|
|
| def _send_304(self, etag): |
| self.send_response(304) |
| self.send_header("ETag", etag) |
| self.send_header("Cache-Control", "no-cache") |
| self._cors() |
| self.end_headers() |
|
|
| def _send_rate_limited(self): |
| body = b'{"error":"rate limited, slow down"}' |
| self.send_response(429) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("Retry-After", "2") |
| self._cors() |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def _send_json_cacheable(self, obj): |
| """Like _send_json but with an ETag so an unchanged body costs the client |
| only a 304 (no re-download). Used for /api/stops, which the apps fetch |
| repeatedly but which rarely changes. `no-cache` = always revalidate.""" |
| body = json.dumps(obj, ensure_ascii=False).encode("utf-8") |
| etag = '"%s"' % hashlib.md5(body).hexdigest()[:16] |
| if self._etag_matches(etag): |
| return self._send_304(etag) |
| gzipped = False |
| if self._accepts_gzip() and len(body) > 1024: |
| body = _gzip.compress(body, 6) |
| gzipped = True |
| self.send_response(200) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| if gzipped: |
| self.send_header("Content-Encoding", "gzip") |
| self.send_header("Vary", "Accept-Encoding") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("ETag", etag) |
| self.send_header("Cache-Control", "no-cache") |
| self._cors() |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def _send_text(self, text, ctype="text/plain; charset=utf-8", status=200): |
| body = text.encode("utf-8") |
| gzipped = False |
| if self._accepts_gzip() and len(body) > 1024: |
| body = _gzip.compress(body, 6) |
| gzipped = True |
| self.send_response(status) |
| self.send_header("Content-Type", ctype) |
| if gzipped: |
| self.send_header("Content-Encoding", "gzip") |
| self.send_header("Vary", "Accept-Encoding") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("Cache-Control", "no-store") |
| self._cors() |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def _accepts_gzip(self): |
| return "gzip" in (self.headers.get("Accept-Encoding") or "").lower() |
|
|
| def _send_file(self, path): |
| ext = os.path.splitext(path)[1].lower() |
| |
| |
| |
| st = os.stat(path) |
| etag = '"%x-%x"' % (int(st.st_mtime), st.st_size) |
| if self._etag_matches(etag): |
| return self._send_304(etag) |
| with open(path, "rb") as fh: |
| body = fh.read() |
| gzipped = False |
| if ext in GZIP_EXTS and self._accepts_gzip() and len(body) > 1024: |
| body = _gzip.compress(body, 6) |
| gzipped = True |
| self.send_response(200) |
| self.send_header( |
| "Content-Type", CONTENT_TYPES.get(ext, "application/octet-stream") |
| ) |
| if gzipped: |
| self.send_header("Content-Encoding", "gzip") |
| self.send_header("Vary", "Accept-Encoding") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("ETag", etag) |
| self.send_header("Last-Modified", formatdate(st.st_mtime, usegmt=True)) |
| self.send_header("Cache-Control", "no-cache") |
| self._cors() |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def do_GET(self): |
| parsed = urlparse(self.path) |
| path = parsed.path |
| self._begin(path) |
| |
| |
| |
| if path.startswith("/api/") and path != "/api/health": |
| if not RATE_LIMITER.allow(getattr(self, "_client_ip", "?")): |
| return self._send_rate_limited() |
| try: |
| if path == "/metrics": |
| return self._send_text(METRICS.render(), "text/plain; version=0.0.4") |
|
|
| if path == "/status": |
| return self._send_text(_status_html(), "text/html; charset=utf-8") |
|
|
| if path == "/api/stops": |
| return self._send_json_cacheable(busapi.get_stops()) |
|
|
| if path == "/api/plan": |
| q = parse_qs(parsed.query) |
| day = int((q.get("day") or ["0"])[0]) |
| frm = (q.get("from") or [""])[0] |
| to = (q.get("to") or [""])[0] |
| if not frm or not to: |
| return self._send_json({"error": "from and to are required"}, 400) |
| if frm == to: |
| METRICS.observe_plan("same_stop") |
| return self._send_json( |
| {"error": "Origin and destination are the same."}, 400 |
| ) |
| result = busapi.plan(day, frm, to) |
| _attach_coords(result) |
| |
| |
| METRICS.observe_plan("success" if result.get("options") else "no_route") |
| return self._send_json(result) |
|
|
| if path == "/api/walk": |
| |
| |
| q = parse_qs(parsed.query) |
| try: |
| fa = [float(x) for x in (q.get("from") or [""])[0].split(",")] |
| ta = [float(x) for x in (q.get("to") or [""])[0].split(",")] |
| except ValueError: |
| fa = ta = [] |
| if len(fa) != 2 or len(ta) != 2: |
| return self._send_json( |
| {"error": "from and to must be 'lat,lon'"}, 400 |
| ) |
| route = roads.walk_route(fa, ta) |
| if not route: |
| return self._send_json({"error": "no walking route"}, 502) |
| return self._send_json(route) |
|
|
| if path == "/api/geocode": |
| |
| q = (parse_qs(parsed.query).get("q") or [""])[0] |
| if not q.strip(): |
| return self._send_json({"results": []}) |
| return self._send_json({"results": geocode.geocode_search(q)}) |
|
|
| if path == "/api/nearby": |
| |
| |
| |
| q = parse_qs(parsed.query) |
| try: |
| lat = float((q.get("lat") or [""])[0]) |
| lon = float((q.get("lon") or [""])[0]) |
| except ValueError: |
| return self._send_json({"error": "lat and lon are required"}, 400) |
| radius = int((q.get("radius") or ["7000"])[0]) |
| cat = (q.get("cat") or [""])[0] |
| if cat and cat in nearby.CATEGORIES: |
| results = nearby.nearby_category(lat, lon, radius, cat) |
| else: |
| results = nearby.nearby_activities(lat, lon, radius) |
| return self._send_json({"results": results}) |
|
|
| if path == "/api/events": |
| |
| return self._send_json({"events": events_mod.get_events()}) |
|
|
| if path == "/api/health": |
| |
| |
| |
| |
| ev = events_mod.health() |
| try: |
| stops = len(busapi.get_stops()) |
| except Exception: |
| stops = 0 |
| try: |
| geo_cache = len(geocode._load_coords_cache()) |
| except Exception: |
| geo_cache = 0 |
| degraded = stops == 0 or ev["count"] < EVENTS_MIN_HEALTHY |
| body = { |
| "status": "degraded" if degraded else "ok", |
| "stops": stops, |
| "events": ev, |
| "geocode_cache": geo_cache, |
| } |
| return self._send_json(body, 503 if degraded else 200) |
|
|
| |
| rel = path.lstrip("/") or "index.html" |
| full = os.path.normpath(os.path.join(PUBLIC, rel)) |
| if not full.startswith(PUBLIC): |
| return self._send_json({"error": "forbidden"}, 403) |
| if os.path.isdir(full): |
| full = os.path.join(full, "index.html") |
| if os.path.isfile(full): |
| return self._send_file(full) |
| return self._send_json({"error": "not found"}, 404) |
|
|
| except Exception as exc: |
| |
| |
| ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) |
| print(f"[{ts}] ERROR {self.path}: {type(exc).__name__}: {exc}", flush=True) |
| traceback.print_exc() |
| return self._send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) |
|
|
| def do_POST(self): |
| parsed = urlparse(self.path) |
| self._begin(parsed.path) |
| if parsed.path != "/api/clienterror": |
| return self._send_json({"error": "not found"}, 404) |
| |
| try: |
| n = min(int(self.headers.get("Content-Length") or 0), _CLIENT_ERR_MAX_BYTES) |
| raw = self.rfile.read(n) if n > 0 else b"" |
| payload = json.loads(raw.decode("utf-8")) if raw else {} |
| except Exception: |
| payload = {} |
| |
| now = time.time() |
| _client_err_hits[:] = [ |
| t for t in _client_err_hits if now - t < _CLIENT_ERR_WINDOW |
| ] |
| if len(_client_err_hits) < _CLIENT_ERR_MAX_PER_WINDOW: |
| _client_err_hits.append(now) |
| msg = str(payload.get("msg", ""))[:300] |
| where = str(payload.get("where", ""))[:120] |
| platform = str(payload.get("platform", ""))[:60] |
| ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) |
| |
| print(f"[{ts}] CLIENT-ERR [{platform}] {where}: {msg}", flush=True) |
| METRICS.inc_client_error() |
| else: |
| METRICS.inc_client_error(dropped=True) |
| |
| self.send_response(204) |
| self._cors() |
| self.send_header("Content-Length", "0") |
| self.end_headers() |
|
|
|
|
| def main(): |
| |
| print("Loading bus stops...", flush=True) |
| stops = busapi.get_stops() |
| print(f" {len(stops)} stops cached.", flush=True) |
| print("Building OpenStreetMap geocoding index...", flush=True) |
| try: |
| idx = geocode._build_osm_index() |
| print(f" {len(idx)} named features indexed.", flush=True) |
| except Exception as exc: |
| print( |
| f" WARN: OSM index unavailable ({exc}); will rely on Nominatim.", |
| flush=True, |
| ) |
|
|
| |
| |
| print("Pre-warming events (background)...", flush=True) |
| events_mod.prewarm() |
|
|
| try: |
| httpd = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) |
| except OSError as exc: |
| if exc.errno in (48, 98): |
| print( |
| f"\nPort {PORT} is already in use — another copy may still be running.", |
| file=sys.stderr, |
| ) |
| print(f" Free it: lsof -ti tcp:{PORT} | xargs kill", file=sys.stderr) |
| print( |
| f" Or pick another port: PORT={PORT + 1} ./run.sh", file=sys.stderr |
| ) |
| sys.exit(1) |
| raise |
|
|
| print( |
| f"\nMauritius Bus Route Planner running at http://localhost:{PORT}", flush=True |
| ) |
| print("Press Ctrl+C to stop.", flush=True) |
| try: |
| httpd.serve_forever() |
| except KeyboardInterrupt: |
| print("\nShutting down.") |
| httpd.server_close() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|