feat(edge): add offline read cache for allowlisted State Hub GET routes
The edge relay now persists successful GET responses and serves them with stale markers when upstream is unreachable. Extend Forgejo image workflow path filters so api changes trigger registry publishes.
This commit is contained in:
parent
138299293d
commit
1cf949bda4
5 changed files with 354 additions and 1 deletions
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
|
@ -9,6 +10,12 @@ from fastapi import FastAPI, Request
|
|||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from api.edge.outbox import OutboxEnvelope, OutboxStore, PayloadRejected, default_outbox_path
|
||||
from api.edge.read_cache import (
|
||||
ReadCacheStore,
|
||||
build_cache_key,
|
||||
default_read_cache_path,
|
||||
is_cacheable_get,
|
||||
)
|
||||
from api.services.write_idempotency import route_class_for
|
||||
|
||||
HOP_BY_HOP_HEADERS = {
|
||||
|
|
@ -100,15 +107,35 @@ async def replay_pending(
|
|||
return counts
|
||||
|
||||
|
||||
def _cache_age_seconds(cached_at: str) -> int:
|
||||
try:
|
||||
cached = datetime.fromisoformat(cached_at)
|
||||
except ValueError:
|
||||
return 0
|
||||
if cached.tzinfo is None:
|
||||
cached = cached.replace(tzinfo=timezone.utc)
|
||||
return max(0, int((datetime.now(timezone.utc) - cached).total_seconds()))
|
||||
|
||||
|
||||
def _stale_cache_headers(cached_at: str) -> dict[str, str]:
|
||||
return {
|
||||
"X-StateHub-Edge-Cache": "stale",
|
||||
"X-StateHub-Edge-Cache-Age": str(_cache_age_seconds(cached_at)),
|
||||
}
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
upstream_url: str | None = None,
|
||||
outbox_path: str | None = None,
|
||||
read_cache_path: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
) -> FastAPI:
|
||||
upstream = (upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000").rstrip("/")
|
||||
store_path = outbox_path or default_outbox_path()
|
||||
cache_path = read_cache_path or str(default_read_cache_path())
|
||||
store_instance: OutboxStore | None = None
|
||||
cache_instance: ReadCacheStore | None = None
|
||||
|
||||
def get_store() -> OutboxStore:
|
||||
nonlocal store_instance
|
||||
|
|
@ -116,6 +143,12 @@ def create_app(
|
|||
store_instance = OutboxStore(store_path)
|
||||
return store_instance
|
||||
|
||||
def get_cache() -> ReadCacheStore:
|
||||
nonlocal cache_instance
|
||||
if cache_instance is None:
|
||||
cache_instance = ReadCacheStore(cache_path)
|
||||
return cache_instance
|
||||
|
||||
app = FastAPI(title="State Hub Edge Relay", version="0.1.0")
|
||||
|
||||
@app.get("/edge/health")
|
||||
|
|
@ -134,6 +167,7 @@ def create_app(
|
|||
"upstream_reachable": reachable,
|
||||
"upstream_error": error,
|
||||
"outbox": get_store().summary(),
|
||||
"read_cache": get_cache().summary(),
|
||||
}
|
||||
|
||||
@app.post("/edge/replay")
|
||||
|
|
@ -156,6 +190,14 @@ def create_app(
|
|||
if request.headers.get("content-type"):
|
||||
headers["Content-Type"] = request.headers["content-type"]
|
||||
|
||||
cache_key = None
|
||||
if request.method == "GET" and is_cacheable_get(api_path):
|
||||
cache_key = build_cache_key(
|
||||
request.method,
|
||||
api_path,
|
||||
list(request.query_params.multi_items()),
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=upstream, timeout=timeout) as client:
|
||||
response = await client.request(
|
||||
|
|
@ -165,13 +207,36 @@ def create_app(
|
|||
json=body if body is not None else None,
|
||||
headers=headers,
|
||||
)
|
||||
if cache_key is not None and 200 <= response.status_code < 300:
|
||||
get_cache().put(
|
||||
cache_key=cache_key,
|
||||
status_code=response.status_code,
|
||||
content=response.content,
|
||||
content_type=response.headers.get("content-type"),
|
||||
)
|
||||
response_headers = _safe_response_headers(response.headers)
|
||||
if cache_key is not None:
|
||||
response_headers["X-StateHub-Edge-Cache"] = "hit"
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=_safe_response_headers(response.headers),
|
||||
headers=response_headers,
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
if cache_key is not None:
|
||||
cached = get_cache().get(cache_key)
|
||||
if cached is not None:
|
||||
stale_headers = _stale_cache_headers(cached.cached_at)
|
||||
if cached.content_type:
|
||||
stale_headers["Content-Type"] = cached.content_type
|
||||
return Response(
|
||||
content=cached.content,
|
||||
status_code=cached.status_code,
|
||||
headers=stale_headers,
|
||||
media_type=cached.content_type,
|
||||
)
|
||||
|
||||
route_class = route_class_for(request.method, api_path)
|
||||
if route_class is None or request.method not in {"POST", "PATCH"}:
|
||||
return JSONResponse(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue