GODSIGH/serve.py
jing 108680462e wave2 phase 3: shared OpenSky cache (quota-collision fix)
serve.py now implements the OpenSky shared cache v1 contract (SPEC2 §4), so
GODSIGH and godstrument stop racing each other into the anonymous daily quota:

- Serve-from-cache: a fresh (<120 s) GLOBAL states request returns the cached
  body with X-Godsigh-Cache: hit, spending zero upstream quota.
- Write-through: a healthy global fetch writes the raw body to
  ~/.cache/godverse/opensky-states.json (OPENSKY_CACHE_FILE override) atomically
  — unique temp beside the target, then os.replace — so concurrent writers
  never corrupt or expose a half-written cache.
- Stale-on-error: on upstream 429 or connection failure, serve the last-known
  cache with 200 + X-Godsigh-Cache: stale instead of failing the client.
- Bbox requests bypass the cache (the app default is global).

Verified: miss (4.0s real fetch, quota was down to 2) → hit (0.0014s) →
stale (backdated cache + upstream 429 → 200 stale) → bbox bypass (no cache
header). App served 6062 aircraft straight from cache while quota exhausted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:12:36 +10:00

172 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""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
# can't call it directly — we forward it here instead.
"opensky": "https://opensky-network.org/api/states/all",
# Celestrak sends ACAO:* today; proxied too so the app keeps working if that changes.
"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):
# Dev server: never let the browser cache our static assets, so edits to
# js/css always take effect on reload (module graphs cache aggressively).
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if not self.path.startswith("/proxy/"):
return super().do_GET()
name, _, query = self.path[len("/proxy/"):].partition("?")
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 "")
passthrough = {}
def grab(hdrs):
for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
if hdrs.get(k) is not None:
passthrough[k] = hdrs.get(k)
try:
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
with urllib.request.urlopen(req, timeout=45) as r:
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, 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()
self.wfile.write(body)
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
print(f"GODSIGH serving on http://127.0.0.1:{port}")
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()