Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
22 changes: 20 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}

Expand Down
50 changes: 50 additions & 0 deletions service.yaml
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading