diff --git a/constants.py b/constants.py index ea26e48..a8b1d2a 100644 --- a/constants.py +++ b/constants.py @@ -11,3 +11,6 @@ MSG_REFRESH_FAILED = ( "Cache refresh failed, continuing to serve the last known-good cache: {error}" ) +MSG_INDEX_REFRESH_FAILED = ( + "Index could not refresh the cache, reporting the last known state: {error}" +) diff --git a/main.py b/main.py index 2f06119..4e493ac 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from slowapi.util import get_remote_address from constants import ( + MSG_INDEX_REFRESH_FAILED, MSG_INITIAL_FETCH_FAILED, MSG_MITIGATION_NOT_FOUND, MSG_RECORD_NOT_FOUND, @@ -85,13 +86,30 @@ async def startup() -> None: @app.get("/") -async def index() -> dict: +@limiter.limit("60/minute") +async def index(request: Request) -> dict: + try: + await _ensure_fresh() + except Exception as e: + # The index stays reachable so a person or an uptime check can see + # that data is missing. /records still fails loudly when the cache + # has never been populated. + logger.warning(MSG_INDEX_REFRESH_FAILED.format(error=e)) + fetched_at = _cache["fetched_at"] + age = round(time.time() - fetched_at) if fetched_at else None + if not _cache["records"]: + status = "no data yet" + elif age > REFRESH_INTERVAL_SECONDS: + status = "stale" + else: + status = "ok" return { "name": SERVICE_NAME, "standard": STANDARD_URL, "repo": REPO_URL, + "status": status, "record_count": len(_cache["records"]), - "cache_age_seconds": round(time.time() - _cache["fetched_at"]), + "cache_age_seconds": age, "docs": "/docs", } diff --git a/service.yaml b/service.yaml new file mode 100644 index 0000000..54511be --- /dev/null +++ b/service.yaml @@ -0,0 +1,50 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + annotations: + run.googleapis.com/build-enable-automatic-updates: 'false' + run.googleapis.com/build-id: e9b60b42-1ffb-4d15-9d87-f8f840c14d30 + run.googleapis.com/build-image-uri: us-central1-docker.pkg.dev/project-e41cd060-d62a-477e-92b/cloud-run-source-deploy/ave-api + run.googleapis.com/build-name: projects/303150375503/locations/us-central1/builds/e9b60b42-1ffb-4d15-9d87-f8f840c14d30 + run.googleapis.com/build-source-location: gs://run-sources-project-e41cd060-d62a-477e-92b-us-central1/services/ave-api/1784734143.308506-983e673023cd447395a3ca22eab70e8b.zip#1784734143964136 + run.googleapis.com/ingress: all + run.googleapis.com/ingress-status: all + run.googleapis.com/maxScale: '1' + run.googleapis.com/urls: '["https://ave-api-303150375503.us-central1.run.app","https://ave-api-2q5w6u36fa-uc.a.run.app"]' + labels: + cloud.googleapis.com/location: us-central1 + name: ave-api + namespace: '303150375503' +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/maxScale: '3' + run.googleapis.com/client-name: gcloud + run.googleapis.com/client-version: 576.0.0 + run.googleapis.com/startup-cpu-boost: 'true' + labels: + client.knative.dev/nonce: edcjtcmojy + run.googleapis.com/startupProbeType: Default + spec: + containerConcurrency: 80 + containers: + - image: us-central1-docker.pkg.dev/project-e41cd060-d62a-477e-92b/cloud-run-source-deploy/ave-api@sha256:a91c837ea9dccee8c3c24cb29be3653a227c54186d9b2fea1417ba41b09290b6 + ports: + - containerPort: 8080 + name: http1 + resources: + limits: + cpu: '1' + memory: 256Mi + startupProbe: + failureThreshold: 1 + periodSeconds: 240 + tcpSocket: + port: 8080 + timeoutSeconds: 240 + serviceAccountName: 303150375503-compute@developer.gserviceaccount.com + timeoutSeconds: 300 + traffic: + - latestRevision: true + percent: 100 diff --git a/tests/test_main.py b/tests/test_main.py index 1c92ca9..e9e44f7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -132,3 +132,75 @@ async def failing_refresh() -> None: response = test_client.get("/records") assert response.status_code == 500 + + +def _flaky_refresh(monkeypatch, fail_first): + """Replace the cache refresh with one that fails `fail_first` times and then + succeeds, and return the call counter.""" + calls = {"n": 0} + + async def refresh() -> None: + calls["n"] += 1 + if calls["n"] <= fail_first: + raise RuntimeError("simulated network failure") + main._cache["records"] = FAKE_RECORDS + main._cache["fetched_at"] = time.time() + + monkeypatch.setattr(main, "_refresh_cache", refresh) + return calls + + +def test_index_retries_a_cache_whose_startup_fetch_failed(monkeypatch): + calls = _flaky_refresh(monkeypatch, fail_first=1) + + with TestClient(main.app) as test_client: + body = test_client.get("/").json() + + assert calls["n"] == 2 + assert body["record_count"] == len(FAKE_RECORDS) + assert body["status"] == "ok" + assert body["cache_age_seconds"] < 5 + + +def test_index_refreshes_a_stale_cache(client, monkeypatch): + calls = _flaky_refresh(monkeypatch, fail_first=0) + main._cache["fetched_at"] = time.time() - main.REFRESH_INTERVAL_SECONDS - 1 + + body = client.get("/").json() + + assert calls["n"] == 1 + assert body["status"] == "ok" + assert body["cache_age_seconds"] < 5 + + +def test_index_reports_no_data_not_a_nonsense_age_while_fetching_keeps_failing( + monkeypatch, +): + _flaky_refresh(monkeypatch, fail_first=10**6) + + with TestClient(main.app) as test_client: + response = test_client.get("/") + + assert response.status_code == 200 + body = response.json() + assert body["record_count"] == 0 + assert body["status"] == "no data yet" + assert body["cache_age_seconds"] is None + + +def test_index_says_stale_when_refresh_fails_and_the_cache_is_old(client, monkeypatch): + _flaky_refresh(monkeypatch, fail_first=10**6) + main._cache["fetched_at"] = time.time() - main.REFRESH_INTERVAL_SECONDS - 60 + + body = client.get("/").json() + + assert body["status"] == "stale" + assert body["record_count"] == len(FAKE_RECORDS) + assert body["cache_age_seconds"] > main.REFRESH_INTERVAL_SECONDS + + +def test_the_index_is_rate_limited_too(client): + for _ in range(60): + assert client.get("/").status_code == 200 + + assert client.get("/").status_code == 429