CoolFace
Apppublic

mangrovedigital/tide-engine-api

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
api_server.py631 linesDownload Raw Back to root
1"""Tides API — the connection layer between the engine and your UI.2 3Zero dependencies (Python stdlib only). Run:4    python api_server.py            # serves http://localhost:87875Then hit http://localhost:8787/ in a browser to see every endpoint.6 7All levels are FEET, datum NAVD88. All times are UTC (epoch seconds + ISO);8each spot carries a `tz` so the UI formats local time. Every response has a9`status` field: "ok" | "hold:<reason>" | "error:<reason>" — render HOLD as10"unavailable", never a fake number.11 12FLORIDA BAY (2026-07-01): spots/points with no representative live gauge ride a13NOAA predicted-tide anchor instead — response carries units.datum = "MLLW14(station datum)", a `wind_correction` HOLD note, and `limitations`. Render the15flag; never imply a wind-corrected number there.16 17ENDPOINTS (the contract your UI calls):18  GET /api/spots19  GET /api/forecast?spot=east-river&hours=7220  GET /api/now?spot=east-river21  GET /api/alerts?spot=east-river&threshold_ft=0.322  GET /api/navigability?spot=east-river&lat=..&lon=..&draft_ft=2.023"""24import os, json, time, math, threading, logging25from collections import OrderedDict26from urllib.parse import urlparse, parse_qs27from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer28 29from engine import spots as spots_mod30from engine.forecast import TideForecaster31from engine import navigability as nav_mod32from engine import point_forecast33from engine import sources34from engine import wind_sentinel35import waypoint_export36 37# ---- observability (L1): structured request logging + error counter ----38logging.basicConfig(level=os.environ.get("TIDES_LOG_LEVEL", "INFO"), format="%(message)s")39_log = logging.getLogger("tides")40_metrics = {"requests": 0, "errors": 0}41 42PORT = int(os.environ.get("PORT", 8790))  # hosts (Render/Fly/Railway) set $PORT43M2FT = 3.2808444 45# ---- production config (H1/H6): env-tunable, safe-by-default ----46API_KEY = os.environ.get("TIDES_API_KEY")                    # if set -> required47RATE_LIMIT = int(os.environ.get("TIDES_RATE_LIMIT", "60"))   # requests / window / caller48RATE_WINDOW = float(os.environ.get("TIDES_RATE_WINDOW", "60"))49CORS_ORIGINS = [o.strip() for o in os.environ.get("TIDES_CORS_ORIGINS", "").split(",") if o.strip()]50MAX_BODY = int(os.environ.get("TIDES_MAX_BODY", str(256 * 1024)))   # 256 KB POST cap51CACHE_MAX = int(os.environ.get("TIDES_CACHE_MAX", "256"))52CACHE_TTL = 60053REGION_LAT = (23.0, 30.0)     # SW FL / Everglades / Keys / Tampa / Big Bend envelope54REGION_LON = (-84.0, -79.0)55 56# ---- bounded LRU payload cache (H1) ----57_cache = OrderedDict()         # key -> (ts, payload)58_cache_lock = threading.Lock()59 60 61def cached(key, fn):62    with _cache_lock:63        hit = _cache.get(key)64        if hit and time.time() - hit[0] < CACHE_TTL:65            _cache.move_to_end(key)66            return hit[1]67    val = fn()                 # compute outside the lock68    with _cache_lock:69        _cache[key] = (time.time(), val)70        _cache.move_to_end(key)71        while len(_cache) > CACHE_MAX:72            _cache.popitem(last=False)73    return val74 75 76# ---- token-bucket rate limit per caller key/IP (H1) ----77_buckets = {}                  # caller -> (tokens, last_ts)78_buckets_lock = threading.Lock()79 80 81def rate_ok(caller):82    now = time.time()83    with _buckets_lock:84        tokens, last = _buckets.get(caller, (float(RATE_LIMIT), now))85        tokens = min(RATE_LIMIT, tokens + (now - last) * (RATE_LIMIT / RATE_WINDOW))86        if tokens < 1.0:87            _buckets[caller] = (tokens, now)88            return False89        _buckets[caller] = (tokens - 1.0, now)90        if len(_buckets) > 4096:      # opportunistic cleanup (unbounded-dict guard)91            stale = [k for k, (_, l) in _buckets.items() if now - l > RATE_WINDOW * 4]92            for k in stale[:1024]:93                _buckets.pop(k, None)94        return True95 96 97def region_ok(lat, lon):98    return (math.isfinite(lat) and math.isfinite(lon)99            and REGION_LAT[0] <= lat <= REGION_LAT[1]100            and REGION_LON[0] <= lon <= REGION_LON[1])101 102 103# ---- derived data the UI wants ----104def extremes(series, key, win=3):105    """Local highs/lows of `key` -> [{epoch,t,type,level_ft}]."""106    pts = [p for p in series if p.get(key) is not None]107    out = []108    for i in range(win, len(pts) - win):109        v = pts[i][key]110        seg = [pts[j][key] for j in range(i - win, i + win + 1)]111        if v == max(seg) and v > min(seg):112            out.append({"epoch": pts[i]["epoch"], "t": pts[i]["t"], "type": "high", "level_ft": v})113        elif v == min(seg) and v < max(seg):114            out.append({"epoch": pts[i]["epoch"], "t": pts[i]["t"], "type": "low", "level_ft": v})115    return out116 117 118def alerts(series, threshold_ft=0.3, offset_ft=0.0):119    """Sustained WIND-DRIVEN divergence from now -> dump/fill events (M6).120 121    divergence = corrected - chart - r0   (r0 = anchor_offset_ft)122    The standing anchor offset r0 is a constant seasonal/tidal-datum offset, NOT a123    wind event; including it (the old `corrected - chart`) fired perpetual alerts124    with zero wind change. Here we threshold only the wind-driven CHANGE, matching125    the product's "wind is dropping/raising water vs now" claim."""126    def dvg(p):127        return p["corrected_ft"] - p["tide_ft"] - offset_ft128    fut = [p for p in series if p["lead_h"] > 0 and p["corrected_ft"] is not None]129    evts = []; run = []130    def flush(run):131        if not run:132            return133        peak = max(run, key=lambda p: abs(dvg(p)))134        d = round(dvg(peak), 2)135        if abs(d) < threshold_ft:136            return137        kind = "dump" if d < 0 else "fill"138        evts.append({139            "type": kind,140            "window_start": run[0]["t"], "window_end": run[-1]["t"],141            "peak_epoch": peak["epoch"], "peak_t": peak["t"],142            "peak_delta_ft": d,143            "headline": "Wind %s water ~%.1f ft %s current" % (144                "dropping" if d < 0 else "raising", abs(d), "below" if d < 0 else "above"),145        })146    for p in fut:147        if abs(dvg(p)) >= threshold_ft:148            run.append(p)149        else:150            flush(run); run = []151    flush(run)152    return evts153 154 155# ---- endpoint handlers (return (status_code, dict)) ----156def h_spots(q):157    return 200, {"status": "ok",158                 "spots": [spots_mod.public(s) for s in spots_mod.SPOTS.values()]}159 160 161def _forecast_payload(spot, hours):162    # route preset spots through the universal point engine (uses lat/lon, nearest163    # anchor, datum-gated passability) so presets and GPS share one code path.164    out = point_forecast.forecast_at_gps(spot["lat"], spot["lon"], horizon_h=hours)165    ok = out.get("status") == "OK"166    series = out.get("series", [])167    return {168        "status": "ok" if ok else "hold:" + str(out.get("status")),169        "spot": spots_mod.public(spot),170        "issued_utc": out.get("issued_utc"), "anchor": out.get("anchor"),171        "anchor_offset_ft": out.get("anchor_offset_ft"),172        # units come from the engine: NAVD88 on live-gauge anchors, MLLW on173        # NOAA prediction anchors (Florida Bay hybrid) — never hardcoded.174        "units": out.get("units") or {"level": "ft", "datum": "NAVD88", "time": "UTC"},175        "series": series,176        "extremes": {"chart": extremes(series, "tide_ft"),177                     "corrected": extremes(series, "corrected_ft")},178        "passability": out.get("passability"),179        "provenance": out.get("provenance"),180        # honesty flags from prediction-only anchors pass through untouched181        "wind_correction": out.get("wind_correction"),182        "limitations": out.get("limitations"),183        "microtidal": out.get("microtidal", False),184        "noaa_extremes": out.get("extremes"),185    }186 187 188def h_forecast(q):189    spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])190    if not spot:191        return 404, {"status": "error:unknown spot"}192    hours = int((q.get("hours") or ["72"])[0])193    return 200, cached(("fc", spot["id"], hours), lambda: _forecast_payload(spot, hours))194 195 196def h_now(q):197    spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])198    if not spot:199        return 404, {"status": "error:unknown spot"}200    fc = cached(("fc", spot["id"], 72), lambda: _forecast_payload(spot, 72))201    now_pt = next((p for p in fc["series"] if p.get("is_now")), None)202    if fc["status"].startswith("hold") or now_pt is None:203        return 200, {"status": fc["status"] if fc["status"] != "ok" else "hold:no current point",204                     "spot": spots_mod.public(spot)}205    return 200, {"status": "ok", "spot": spots_mod.public(spot),206                 "t_utc": now_pt["t"],207                 "tide_ft": now_pt["tide_ft"], "corrected_ft": now_pt["corrected_ft"],208                 "observed_ft": now_pt["observed_ft"],209                 "offset_ft": fc["anchor_offset_ft"],210                 "wind": {"kt": now_pt["wind_kt"], "from_deg": now_pt["wind_dir_from"]},211                 "wind_correction": fc.get("wind_correction"),212                 "units": fc.get("units")}213 214 215def h_alerts(q):216    spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])217    if not spot:218        return 404, {"status": "error:unknown spot"}219    thr = float((q.get("threshold_ft") or ["0.3"])[0])220    fc = cached(("fc", spot["id"], 72), lambda: _forecast_payload(spot, 72))221    if fc["status"].startswith("hold"):222        return 200, {"status": fc["status"], "spot": spots_mod.public(spot), "alerts": []}223    return 200, {"status": "ok", "spot": spots_mod.public(spot),224                 "alerts": alerts(fc["series"], thr, fc.get("anchor_offset_ft") or 0.0)}225 226 227def h_navigability(q):228    # passability via the universal engine (respects the NAVD88 datum gate -> no229    # false-confident depth at gage-height anchors). Accepts spot= or lat/lon.230    # M2: honor draft_ft (validated). Absent -> engine default (0.6 m ≈ 2 ft).231    draft_m = 0.6232    dq = q.get("draft_ft")233    if dq:234        try:235            dft = float(dq[0])236        except (TypeError, ValueError):237            return 400, {"status": "error:draft_ft must be numeric"}238        if not (math.isfinite(dft) and 0.0 < dft <= 10.0):239            return 400, {"status": "error:draft_ft must be finite and within (0, 10] ft"}240        draft_m = dft / 3.28084241    qlat = q.get("lat"); qlon = q.get("lon")242    if qlat and qlon:243        try:244            lat = float(qlat[0]); lon = float(qlon[0])245        except (TypeError, ValueError):246            return 400, {"status": "error:lat/lon must be numeric"}247        if not region_ok(lat, lon):248            return 400, {"status": "error:lat/lon must be finite and within the service region"}249        out = point_forecast.forecast_at_gps(lat, lon, draft_m=draft_m)250        p = out.get("passability", {"status": "hold:no data"})251        return 200, {"status": p.get("status", "hold"), "gps": out.get("gps"),252                     "draft_ft": round(draft_m * M2FT, 2), "passability": p}253    spot = spots_mod.SPOTS.get((q.get("spot") or [""])[0])254    if not spot:255        return 404, {"status": "error:spot or lat/lon required"}256    out = point_forecast.forecast_at_gps(spot["lat"], spot["lon"], draft_m=draft_m)257    return 200, {"status": out.get("status"), "spot": spots_mod.public(spot),258                 "draft_ft": round(draft_m * M2FT, 2), "passability": out.get("passability")}259 260 261def h_forecast_gps(q):262    """Point-GPS wind-corrected tide + passability at any lat/lon in the region."""263    try:264        lat = float((q.get("lat") or [None])[0]); lon = float((q.get("lon") or [None])[0])265    except (TypeError, ValueError):266        return 400, {"status": "error:lat & lon required"}267    if not region_ok(lat, lon):268        return 400, {"status": "error:lat/lon must be finite and within the service region"}269    return 200, cached(("gps", round(lat, 4), round(lon, 4)),270                       lambda: point_forecast.forecast_at_gps(lat, lon))271 272 273def h_wind_sentinel(q):274    """GET /api/wind_sentinel?lat=&lon= -> trailing wind advisory for the point.275 276    Additive endpoint; does not modify forecast_gps or any existing handler.277    """278    try:279        lat = float((q.get("lat") or [None])[0])280        lon = float((q.get("lon") or [None])[0])281    except (TypeError, ValueError):282        return 400, {"status": "error:lat & lon required"}283    if not region_ok(lat, lon):284        return 400, {"status": "error:lat/lon must be finite and within the service region"}285    return 200, wind_sentinel.wind_sentinel_for_point(lat, lon)286 287 288def h_conditions(q):289    """GET /api/conditions?lat=&lon=&t=  (or ?spot=) -> the full condition stamp:290    tide phase + corrected level, wind, pressure + trend, moon, solunar period,291    anchor/provenance. Powers the journal condition-stamp and the FE display."""292    from engine import finn293    tq = q.get("t")294    t = None295    if tq:296        try:297            t = float(tq[0])298        except (TypeError, ValueError):299            return 400, {"status": "error:t must be epoch seconds"}300    sp = spots_mod.SPOTS.get((q.get("spot") or [""])[0])301    if sp:302        return 200, finn.conditions(sp["lat"], sp["lon"], epoch=t,303                                    tz=sp.get("tz", "America/New_York"),304                                    spot=spots_mod.public(sp))305    qlat = q.get("lat"); qlon = q.get("lon")306    if not (qlat and qlon):307        return 404, {"status": "error:spot or lat/lon required"}308    try:309        lat = float(qlat[0]); lon = float(qlon[0])310    except (TypeError, ValueError):311        return 400, {"status": "error:lat/lon must be numeric"}312    if not region_ok(lat, lon):313        return 400, {"status": "error:lat/lon must be finite and within the service region"}314    return 200, finn.conditions(lat, lon, epoch=t)315 316 317def h_finn_spot_time(q):318    """GET /api/finn/spot_time?lat=&lon=&date=&species= -> 'best time to fish319    this spot on <date>' for a species. Always returns a doctrine-shaped call."""320    from engine import finn321    try:322        lat = float((q.get("lat") or [None])[0]); lon = float((q.get("lon") or [None])[0])323    except (TypeError, ValueError):324        return 400, {"status": "error:lat & lon required"}325    if not region_ok(lat, lon):326        return 400, {"status": "error:lat/lon must be finite and within the service region"}327    date = (q.get("date") or [None])[0]328    species = (q.get("species") or ["snook"])[0]329    return 200, finn.spot_time(lat, lon, date, species)330 331 332def h_finn_plan(body):333    """POST /api/finn/plan {lat,lon (launch),date,species[],bait[],zone,draft_ft?,334    first_name?} -> the three-shape day plan. Returns (code, dict)."""335    from engine import finn336    if not isinstance(body, dict):337        return 400, {"status": "error:body must be a JSON object"}338    try:339        lat = float(body["lat"]); lon = float(body["lon"])340    except (KeyError, TypeError, ValueError):341        return 400, {"status": "error:lat & lon (launch point) required"}342    if not region_ok(lat, lon):343        return 400, {"status": "error:lat/lon must be finite and within the service region"}344    species = body.get("species") or []345    bait = body.get("bait") or []346    if not isinstance(species, list) or not isinstance(bait, list):347        return 400, {"status": "error:species and bait must be lists"}348    zone = body.get("zone")349    if zone is not None:350        try:351            zone = {k: float(zone[k]) for k in ("west", "south", "east", "north")}352        except (KeyError, TypeError, ValueError):353            return 400, {"status": "error:zone must have numeric west/south/east/north"}354    draft_ft = body.get("draft_ft")355    if draft_ft is not None:356        try:357            draft_ft = float(draft_ft)358        except (TypeError, ValueError):359            return 400, {"status": "error:draft_ft must be numeric"}360        if not (0.0 < draft_ft <= 10.0):361            return 400, {"status": "error:draft_ft must be within (0, 10] ft"}362    first_name = body.get("first_name")363    if first_name is not None and not isinstance(first_name, str):364        return 400, {"status": "error:first_name must be a string"}365    return 200, finn.plan(lat, lon, body.get("date"), species, bait=bait, zone=zone,366                          draft_ft=draft_ft, first_name=first_name)367 368 369def h_finn_chat(body):370    """POST /api/finn/chat {messages[],lat?,lon?,first_name?} -> conversational371    Finn. The LLM is the mouth; the engine tools are the only brain. Returns372    (code, dict). User content is never logged."""373    from engine import finn_chat374    if not isinstance(body, dict):375        return 400, {"status": "error:body must be a JSON object"}376    msgs = body.get("messages")377    if not isinstance(msgs, list) or not msgs:378        return 400, {"status": "error:messages[] required"}379    req = {"messages": msgs, "first_name": body.get("first_name")}380    for k in ("lat", "lon"):381        if body.get(k) is not None:382            try:383                req[k] = float(body[k])384            except (TypeError, ValueError):385                return 400, {"status": "error:%s must be numeric" % k}386    out = finn_chat.chat(req)387    code = 200 if out.get("status", "").startswith(("ok", "hold")) else 400388    return code, out389 390 391ROUTES = {"/api/spots": h_spots, "/api/forecast": h_forecast, "/api/now": h_now,392          "/api/alerts": h_alerts, "/api/navigability": h_navigability,393          "/api/forecast_gps": h_forecast_gps, "/api/wind_sentinel": h_wind_sentinel,394          "/api/conditions": h_conditions,395          "/api/finn/spot_time": h_finn_spot_time}396 397POST_ROUTES = {"/api/finn/plan": h_finn_plan, "/api/finn/chat": h_finn_chat}398 399 400def h_export(body):401    """POST /api/export -> the waypoint file itself (not JSON).402 403    body: {"format": "gpx"|"usr", "spots": [{"label","lat","lon"}, ...]}404    Returns (code, headers, bytes). STATELESS: nothing is stored; the file holds405    only the posted spots. Invalid coordinates are skipped + reported in the406    X-Export-Skipped* headers, never emitted as fake points."""407    if not isinstance(body, dict):408        return 400, {}, b'{"status": "error:body must be a JSON object"}'409    try:410        result = waypoint_export.export(body.get("format"), body.get("spots"))411    except ValueError as e:412        return 400, {}, ('{"status": "error:%s"}' % str(e)[:200]).encode()413    except waypoint_export.GPSBabelUnavailable:414        # Can't produce a real, Navico-readable .usr -> HOLD, never a fake file.415        return 503, {}, (b'{"status": "hold:gpsbabel unavailable", '416                         b'"detail": "server missing GPSBabel; .usr export '417                         b'temporarily unavailable"}')418    except waypoint_export.GPSBabelError as e:419        return 502, {}, ('{"status": "error:gpsbabel:%s"}' % str(e)[:200]).encode()420    headers = {421        "Content-Type": result.content_type,422        "Content-Disposition": 'attachment; filename="%s"' % result.filename,423        "X-Export-Count": str(result.count),424    }425    # Report skips/truncations without persisting anything (headers only).426    if result.skipped:427        headers["X-Export-Skipped"] = str(len(result.skipped))428        headers["X-Export-Skipped-Detail"] = json.dumps(429            result.skipped, separators=(",", ":"))[:1800]430    if result.truncated:431        headers["X-Export-Truncated"] = str(len(result.truncated))432    return 200, headers, result.data433 434INDEX = {"service": "Tides API", "units": {"level": "ft", "datum": "NAVD88", "time": "UTC"},435         "endpoints": ["GET /api/spots", "GET /api/forecast?spot=&hours=72",436                       "GET /api/now?spot=", "GET /api/alerts?spot=&threshold_ft=0.3",437                       "GET /api/navigability?spot=&lat=&lon=&draft_ft=2.0",438                       "GET /api/forecast_gps?lat=&lon=  (point-GPS tide + passability)",439                       "GET /api/wind_sentinel?lat=&lon=  (trailing wind advisory)",440                       "GET /api/conditions?lat=&lon=&t=  (or ?spot=)  "441                       "-> full condition stamp: tide phase+level, wind, pressure+trend, moon, solunar",442                       "POST /api/finn/plan  {lat,lon(launch),date,species[],bait[],zone,draft_ft?,first_name?}  "443                       "-> Dockside Finn's three-shape day plan",444                       "GET /api/finn/spot_time?lat=&lon=&date=&species=  -> best time to fish this spot",445                       "POST /api/finn/chat  {messages[],lat?,lon?,first_name?}  "446                       "-> conversational Finn (LLM voice over engine tools)",447                       "POST /api/export  {format:'gpx'|'usr', spots:[{label,lat,lon}]}  "448                       "-> waypoint file (GPX direct, .usr via GPSBabel; stateless)"],449         "status_values": ["ok", "hold:<reason>", "error:<reason>"],450         "florida_bay_note": "prediction-anchored responses carry units.datum='MLLW (station datum)' "451                             "+ a wind_correction HOLD note — render the flag, never a fake corrected number"}452 453 454# TrueTide v2 front page: inline __MAPS_KEY__ placeholder; the Space injects the455# real key via the MAPS_KEY env var. Missing key -> placeholder stays, and the456# page shows its own 'map key not configured' note instead of crashing.457_HTML_PATH = os.path.join(os.path.dirname(__file__), "webapp", "truetide_v2.html")458_HTML_BODY = open(_HTML_PATH, "rb").read().replace(459    b"__MAPS_KEY__",460    os.environ.get("MAPS_KEY", "__MAPS_KEY__").encode()461)462 463 464class Handler(BaseHTTPRequestHandler):465    # ---- CORS: allowlist from env, else '*' for local dev ----466    def _cors_origin(self):467        origin = self.headers.get("Origin")468        if not CORS_ORIGINS:469            return "*"                      # dev default (no allowlist configured)470        if origin and origin in CORS_ORIGINS:471            return origin                   # echo only allowed origins472        return None                         # disallowed -> no CORS header473 474    def _api_key(self):475        return (self.headers.get("X-API-Key")476                or parse_qs(urlparse(self.path).query).get("key", [None])[0])477 478    def _caller(self):479        return self._api_key() or self.client_address[0]480 481    def _guard(self, path):482        """Auth + rate limit. Returns None if allowed, else (code, obj)."""483        if path in ("/health", "/", "/api", "/api/index"):484            return None                     # health + front page + index always open485        if API_KEY and self._api_key() != API_KEY:486            return 401, {"status": "error:unauthorized (missing/invalid API key)"}487        if not rate_ok(self._caller()):488            return 429, {"status": "error:rate limit exceeded"}489        return None490 491    def _observe(self, route):492        ms = round((time.time() - getattr(self, "_t0", time.time())) * 1000, 1)493        code = getattr(self, "_status", 0)494        _metrics["requests"] += 1495        if code >= 500:496            _metrics["errors"] += 1497        _log.info(json.dumps({"route": route, "status": code, "ms": ms,498                              "upstream_fetches": sources.get_fetch_count()},499                             separators=(",", ":")))500 501    def _send(self, code, obj):502        self._status = code503        body = json.dumps(obj, indent=2).encode()504        self.send_response(code)505        self.send_header("Content-Type", "application/json")506        origin = self._cors_origin()507        if origin:508            self.send_header("Access-Control-Allow-Origin", origin)509            if CORS_ORIGINS:510                self.send_header("Vary", "Origin")511        self.send_header("Content-Length", str(len(body)))512        self.end_headers()513        self.wfile.write(body)514 515    def _send_bytes(self, code, headers, body):516        self._status = code517        self.send_response(code)518        for k, v in headers.items():519            self.send_header(k, v)520        origin = self._cors_origin()521        if origin:522            self.send_header("Access-Control-Allow-Origin", origin)523            if CORS_ORIGINS:524                self.send_header("Vary", "Origin")525        self.send_header("Content-Length", str(len(body)))526        self.end_headers()527        self.wfile.write(body)528 529    def do_OPTIONS(self):530        # CORS preflight (POST /api/export from a browser). 204 + headers.531        origin = self._cors_origin()532        self.send_response(204)533        if origin:534            self.send_header("Access-Control-Allow-Origin", origin)535            self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")536            self.send_header("Access-Control-Allow-Headers", "Content-Type, X-API-Key")537            self.send_header("Access-Control-Max-Age", "600")538            if CORS_ORIGINS:539                self.send_header("Vary", "Origin")540        self.send_header("Content-Length", "0")541        self.end_headers()542 543    def do_POST(self):544        self._t0 = time.time(); self._status = 0; sources.reset_fetch_count()545        try:546            self._do_POST()547        finally:548            self._observe(urlparse(self.path).path)549 550    def _do_POST(self):551        u = urlparse(self.path)552        blocked = self._guard(u.path)553        if blocked:554            return self._send(*blocked)555        if u.path != "/api/export" and u.path not in POST_ROUTES:556            return self._send(404, {"status": "error:not found", "see": "/"})557        n = int(self.headers.get("Content-Length") or 0)558        if n > MAX_BODY:559            # drain a BOUNDED amount so the client receives a clean 413 rather560            # than a connection reset; a huge declared body is still capped.561            try:562                self.rfile.read(min(n, MAX_BODY * 8))563            except Exception:564                pass565            return self._send(413, {"status": "error:payload too large (max %d bytes)" % MAX_BODY})566        try:567            raw = self.rfile.read(n) if n > 0 else b""568            body = json.loads(raw or b"{}")569        except (ValueError, json.JSONDecodeError):570            return self._send(400, {"status": "error:invalid JSON body"})571        try:572            if u.path in POST_ROUTES:573                code, obj = POST_ROUTES[u.path](body)574                return self._send(code, obj)575            code, headers, data = h_export(body)576            self._send_bytes(code, headers, data)577        except Exception as e:578            self._send(500, {"status": "error:%s" % str(e)[:200]})579 580    def do_GET(self):581        self._t0 = time.time(); self._status = 0; sources.reset_fetch_count()582        try:583            self._do_GET()584        finally:585            self._observe(urlparse(self.path).path)586 587    def _do_GET(self):588        u = urlparse(self.path)589        if u.path == "/health":590            return self._send(200, {"status": "ok", "requests": _metrics["requests"],591                                    "errors": _metrics["errors"]})592        if u.path == "/":593            self.send_response(200)594            self.send_header("Content-Type", "text/html; charset=utf-8")595            origin = self._cors_origin()596            if origin:597                self.send_header("Access-Control-Allow-Origin", origin)598                if CORS_ORIGINS:599                    self.send_header("Vary", "Origin")600            self.send_header("Content-Length", str(len(_HTML_BODY)))601            self.end_headers()602            self.wfile.write(_HTML_BODY)603            return604        if u.path in ("/api", "/api/index"):605            return self._send(200, INDEX)606        blocked = self._guard(u.path)607        if blocked:608            return self._send(*blocked)609        fn = ROUTES.get(u.path)610        if not fn:611            return self._send(404, {"status": "error:not found", "see": "/api/index"})612        try:613            code, obj = fn(parse_qs(u.query))614            self._send(code, obj)615        except Exception as e:616            self._send(500, {"status": "error:%s" % str(e)[:200]})617 618    def log_message(self, *a):619        pass  # quiet620 621 622if __name__ == "__main__":623    if not API_KEY:624        print("!! WARNING: TIDES_API_KEY not set — the API is OPEN (no auth). "625              "Set TIDES_API_KEY for any non-local deployment.")626    if not CORS_ORIGINS:627        print("!! NOTE: TIDES_CORS_ORIGINS not set — CORS is '*' (dev). "628              "Set an allowlist for production.")629    print("TrueTide API + app on http://localhost:%d  (open / for the app, /api/index for the endpoint list)" % PORT)630    ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()631