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>
This commit is contained in:
parent
3ca9409f31
commit
108680462e
110
serve.py
110
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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user