From 490f053c64d64260043ebf1354d2ccf99b9296fc Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Wed, 3 Jun 2026 13:23:04 +0200 Subject: [PATCH] Improve activity response metadata --- README.md | 2 ++ app/main.py | 80 +++++++++++++++++++++++++++++++------------- tests/test_routes.py | 39 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0d77616..ba0334d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Supported query params: - `theme=dark|light` - `source=all|github|gitea` +Responses also include an `X-Activity-Stale: true|false` header so image consumers can detect when stale cached data was used after an upstream fetch failure. + Examples: ```bash diff --git a/app/main.py b/app/main.py index a754b9e..7d82040 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import logging @@ -128,31 +129,50 @@ async def collect_merged_activity( github_stale = False gitea_stale = False + fetch_names: list[str] = [] + fetch_tasks: list[asyncio.Task[tuple[dict[str, int], bool]]] = [] + if options.source in ("all", "github"): - github_data, github_stale = await _fetch_with_cache( - cache, - gh_key, - lambda: fetch_github_activity( - username=settings.github_username, - token=settings.github_token, - from_date=from_date, - to_date=to_date, - ), + fetch_names.append("github") + fetch_tasks.append( + asyncio.create_task( + _fetch_with_cache( + cache, + gh_key, + lambda: fetch_github_activity( + username=settings.github_username, + token=settings.github_token, + from_date=from_date, + to_date=to_date, + ), + ), + ) ) if options.source in ("all", "gitea"): - gitea_data, gitea_stale = await _fetch_with_cache( - cache, - gt_key, - lambda: fetch_gitea_activity( - base_url=settings.gitea_base_url, - username=settings.gitea_username, - token=settings.gitea_token, - from_date=from_date, - to_date=to_date, - ), + fetch_names.append("gitea") + fetch_tasks.append( + asyncio.create_task( + _fetch_with_cache( + cache, + gt_key, + lambda: fetch_gitea_activity( + base_url=settings.gitea_base_url, + username=settings.gitea_username, + token=settings.gitea_token, + from_date=from_date, + to_date=to_date, + ), + ), + ) ) + for name, (data, stale) in zip(fetch_names, await asyncio.gather(*fetch_tasks), strict=True): + if name == "github": + github_data, github_stale = data, stale + else: + gitea_data, gitea_stale = data, stale + merged = merge_activity(github_data, gitea_data, dates=_date_keys(from_date, to_date)) merged = filter_activity_source(merged, options.source) @@ -169,6 +189,10 @@ def _daily_totals(merged: dict[str, dict[str, int]]) -> dict[str, int]: return {day: int(payload.get("total", 0)) for day, payload in merged.items()} +def _activity_headers(result: ActivityResult) -> dict[str, str]: + return {"X-Activity-Stale": "true" if result.stale else "false"} + + def _image_cache_key(prefix: str, options: QueryOptions, result: ActivityResult) -> str: digest = hashlib.sha1( json.dumps(result.merged, sort_keys=True, separators=(",", ":")).encode("utf-8") @@ -203,7 +227,7 @@ async def activity_json( "stale": result.stale, "activity": result.merged, } - return JSONResponse(content=payload) + return JSONResponse(content=payload, headers=_activity_headers(result)) @app.get("/activity.svg") @@ -222,7 +246,11 @@ async def activity_svg( if cache is not None: cached = cache.get_json(cache_key) if cached is not None: - return Response(content=str(cached.value), media_type="image/svg+xml") + return Response( + content=str(cached.value), + media_type="image/svg+xml", + headers=_activity_headers(result), + ) daily_totals = _daily_totals(result.merged) total = sum(daily_totals.values()) @@ -236,7 +264,7 @@ async def activity_svg( ) if cache is not None: cache.set_json(cache_key, svg) - return Response(content=svg, media_type="image/svg+xml") + return Response(content=svg, media_type="image/svg+xml", headers=_activity_headers(result)) @app.get("/activity.png") @@ -256,7 +284,11 @@ async def activity_png( cached = cache.get_json(cache_key) if cached is not None: png_data = bytes.fromhex(str(cached.value)) - return Response(content=png_data, media_type="image/png") + return Response( + content=png_data, + media_type="image/png", + headers=_activity_headers(result), + ) daily_totals = _daily_totals(result.merged) total = sum(daily_totals.values()) @@ -275,4 +307,4 @@ async def activity_png( raise HTTPException(status_code=500, detail="PNG rendering failed") from exc if cache is not None: cache.set_json(cache_key, png_data.hex()) - return Response(content=png_data, media_type="image/png") + return Response(content=png_data, media_type="image/png", headers=_activity_headers(result)) diff --git a/tests/test_routes.py b/tests/test_routes.py index 5b30066..d3ddf28 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -8,6 +8,31 @@ from app.main import ActivityResult, app from app.settings import get_settings +def test_activity_json_reports_stale_header(monkeypatch) -> None: + monkeypatch.setenv("GITHUB_USERNAME", "octocat") + monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com") + monkeypatch.setenv("GITEA_USERNAME", "octocat") + get_settings.cache_clear() + + async def fake_collect_merged_activity(settings, cache, options): + return ActivityResult( + merged={"2026-01-01": {"github": 1, "gitea": 2, "total": 3}}, + stale=True, + from_date=date(2026, 1, 1), + to_date=date(2026, 1, 1), + days_count=1, + ) + + monkeypatch.setattr("app.main.collect_merged_activity", fake_collect_merged_activity) + + client = TestClient(app) + response = client.get("/activity.json") + + assert response.status_code == 200 + assert response.headers["x-activity-stale"] == "true" + assert response.json()["stale"] is True + + def test_activity_svg_returns_svg_content_type(monkeypatch) -> None: monkeypatch.setenv("GITHUB_USERNAME", "octocat") monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com") @@ -35,4 +60,18 @@ def test_activity_svg_returns_svg_content_type(monkeypatch) -> None: assert response.status_code == 200 assert response.headers["content-type"].startswith("image/svg+xml") + assert response.headers["x-activity-stale"] == "false" assert " None: + monkeypatch.setenv("GITHUB_USERNAME", "octocat") + monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.example.com") + monkeypatch.setenv("GITEA_USERNAME", "octocat") + get_settings.cache_clear() + + client = TestClient(app) + response = client.get("/activity.json?year=2026&days=30") + + assert response.status_code == 400 + assert response.json() == {"detail": "Provide either year or days, not both"}