diff --git a/serve.py b/serve.py index 30b5b23..212400f 100644 --- a/serve.py +++ b/serve.py @@ -1,9 +1,19 @@ #!/usr/bin/env python3 -"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS.""" +"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS. + +Also implements the **OpenSky shared cache v1** contract (see SPEC2.md §4), which +lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP +without racing each other into the anonymous daily quota. The contract is +mirrored verbatim in godstrument's brief — keep the two in sync. +""" +import os import sys +import tempfile +import time import urllib.error import urllib.request from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path UPSTREAMS = { # OpenSky pins Access-Control-Allow-Origin to its own domain, so the browser @@ -13,6 +23,67 @@ UPSTREAMS = { "celestrak": "https://celestrak.org/NORAD/elements/gp.php", } +# ---- OpenSky shared cache v1 ------------------------------------------------ +# Path: ~/.cache/godverse/opensky-states.json (overridable via OPENSKY_CACHE_FILE). +# Content: the raw, unmodified /states/all JSON body (global, no bbox). Freshness +# = file mtime; readers treat <120 s as fresh. On upstream 429/failure a reader +# may serve a stale cache rather than nothing. +OPENSKY_CACHE_FRESH_SEC = 120 + + +def opensky_cache_file(): + override = os.environ.get("OPENSKY_CACHE_FILE") + return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser() + + +def has_bbox(query): + # A global states/all request has no bounding-box params; only those bypass the cache. + q = query.lower() + return any(k in q for k in ("lamin", "lomin", "lamax", "lomax")) + + +def read_cache_if_fresh(path, max_age): + try: + st = path.stat() + except OSError: + return None + if time.time() - st.st_mtime >= max_age: + return None + try: + return path.read_bytes() + except OSError: + return None + + +def read_cache_any(path): + try: + return path.read_bytes() + except OSError: + return None + + +def write_cache(path, body): + # Atomic write: a unique temp file *beside* the target (same filesystem, so + # os.replace is atomic) then os.replace over the target. A unique temp name + # means concurrent writers (GODSIGH + godstrument, or two threads here) never + # corrupt each other's cache or expose a half-written file. Best-effort — + # caching must never break the actual response. + try: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(body) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except OSError: + pass + class Handler(SimpleHTTPRequestHandler): def end_headers(self): @@ -28,8 +99,18 @@ class Handler(SimpleHTTPRequestHandler): base = UPSTREAMS.get(name) if not base: return self.send_error(404, f"unknown upstream {name!r}") + + # Shared cache applies only to the OpenSky global states feed. + opensky_global = name == "opensky" and not has_bbox(query) + cache = opensky_cache_file() + + # 1) Serve-from-cache: fresh (<120 s) global request costs zero quota. + if opensky_global: + fresh = read_cache_if_fresh(cache, OPENSKY_CACHE_FRESH_SEC) + if fresh is not None: + return self._send(200, "application/json", fresh, {"X-Godsigh-Cache": "hit"}) + url = base + ("?" + query if query else "") - # Forward these upstream headers so the client can show quota state. passthrough = {} def grab(hdrs): @@ -43,17 +124,38 @@ class Handler(SimpleHTTPRequestHandler): body = r.read() status, ctype = r.status, r.headers.get("Content-Type", "text/plain") grab(r.headers) + # 2) Write-through: a healthy global fetch refreshes the shared cache. + if opensky_global and status == 200: + write_cache(cache, body) + passthrough["X-Godsigh-Cache"] = "miss" except urllib.error.HTTPError as e: + # 3) Stale-on-error: on 429, keep the client working from last-known cache. + if opensky_global and e.code == 429: + stale = read_cache_any(cache) + if stale is not None: + return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"}) body = e.read() or str(e).encode() status, ctype = e.code, "text/plain" grab(e.headers) except Exception as e: + # Same stale fallback for connection errors/timeouts. + if opensky_global: + stale = read_cache_any(cache) + if stale is not None: + return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"}) body, status, ctype = str(e).encode(), 502, "text/plain" + + self._send(status, ctype, body, passthrough) + + def _send(self, status, ctype, body, extra=None): self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Expose-Headers", "X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After") - for k, v in passthrough.items(): + self.send_header( + "Access-Control-Expose-Headers", + "X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After, X-Godsigh-Cache", + ) + for k, v in (extra or {}).items(): self.send_header(k, v) self.send_header("Content-Length", str(len(body))) self.end_headers()