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>
172 lines
6.6 KiB
Python
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()
|