Skip to content

Commit a70fe1d

Browse files
committed
Cache Simple API responses
closes #1254 Assisted By: Claude Opus 4.6
1 parent f168ae8 commit a70fe1d

4 files changed

Lines changed: 188 additions & 5 deletions

File tree

CHANGES/1254.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added server-side caching and HTTP cache headers (`Cache-Control`, `ETag`) to Simple API responses.

pulp_python/app/cache.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from django.core.exceptions import ObjectDoesNotExist
2+
from django.http import Http404, HttpResponseNotModified
3+
4+
from pulpcore.plugin.cache import CacheKeys, SyncContentCache
5+
from pulpcore.plugin.util import cache_key, get_domain
6+
7+
from pulp_python.app.models import PythonDistribution
8+
9+
ACCEPT_HEADER_KEY = "accept_header"
10+
11+
12+
class PythonApiCache(SyncContentCache):
13+
"""
14+
Cache for the Simple API.
15+
16+
Adds Accept header to the cache key so HTML and JSON responses are cached separately.
17+
Also handles ETag-based conditional requests (304 Not Modified).
18+
"""
19+
20+
def __init__(self, base_key=None):
21+
keys = (CacheKeys.path, CacheKeys.method, ACCEPT_HEADER_KEY)
22+
super().__init__(base_key=base_key, keys=keys)
23+
24+
def __call__(self, func):
25+
original = super().__call__(func)
26+
27+
def with_conditional_get(*args, **kwargs):
28+
response = original(*args, **kwargs)
29+
etag = response.get("ETag")
30+
if etag:
31+
request = self.get_request_from_args(args)
32+
if request and request.META.get("HTTP_IF_NONE_MATCH") == etag:
33+
not_modified = HttpResponseNotModified()
34+
not_modified["ETag"] = etag
35+
if cache_control := response.get("Cache-Control"):
36+
not_modified["Cache-Control"] = cache_control
37+
if x_pulp_cache := response.get("X-PULP-CACHE"):
38+
not_modified["X-PULP-CACHE"] = x_pulp_cache
39+
return not_modified
40+
return response
41+
42+
return with_conditional_get
43+
44+
def make_key(self, request):
45+
all_keys = {
46+
CacheKeys.path: request.path,
47+
CacheKeys.method: request.method,
48+
ACCEPT_HEADER_KEY: request.headers.get("accept", ""),
49+
}
50+
return ":".join(all_keys[k] for k in self.keys)
51+
52+
53+
def find_base_path_cached(request, cached):
54+
"""
55+
Resolve the distribution base_path for use as the Redis cache base_key.
56+
"""
57+
path = request.resolver_match.kwargs["path"]
58+
base_key = cache_key(path)
59+
if cached.exists(base_key=base_key):
60+
return base_key
61+
try:
62+
distro = PythonDistribution.objects.get(base_path=path, pulp_domain=get_domain())
63+
except ObjectDoesNotExist:
64+
raise Http404(f"No PythonDistribution found for base_path {path}")
65+
return cache_key(distro.base_path)

pulp_python/app/pypi/views.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
import logging
23
from datetime import datetime, timedelta, timezone
34
from itertools import chain
@@ -15,7 +16,6 @@
1516
HttpResponseBadRequest,
1617
HttpResponseForbidden,
1718
HttpResponseNotFound,
18-
StreamingHttpResponse,
1919
)
2020
from django.shortcuts import redirect
2121
from drf_spectacular.utils import extend_schema
@@ -31,6 +31,7 @@
3131
from pulpcore.plugin.viewsets import OperationPostponedResponse
3232

3333
from pulp_python.app import tasks
34+
from pulp_python.app.cache import PythonApiCache, find_base_path_cached
3435
from pulp_python.app.models import (
3536
PackageProvenance,
3637
PythonDistribution,
@@ -63,6 +64,7 @@
6364

6465
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
6566
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
67+
CACHE_CONTROL = "max-age=600, public"
6668

6769

6870
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
@@ -297,7 +299,14 @@ def get_provenance_url(self, package, version, filename):
297299
self.base_api_url, f"{base_path}/integrity/{package}/{version}/{filename}/provenance/"
298300
)
299301

302+
@staticmethod
303+
def _make_etag(repo_version):
304+
"""Builds a quoted ETag from the repo version. Changes when content changes."""
305+
raw = f"{repo_version.number}:{repo_version.pulp_created.isoformat()}"
306+
return f'"{hashlib.sha256(raw.encode()).hexdigest()[:16]}"'
307+
300308
@extend_schema(summary="Get index simple page")
309+
@PythonApiCache(base_key=find_base_path_cached)
301310
def list(self, request, path):
302311
"""Gets the simple api html page for the index."""
303312
repo_version, content = self.get_rvc()
@@ -310,15 +319,19 @@ def list(self, request, path):
310319
.iterator()
311320
)
312321
media_type = request.accepted_renderer.media_type
313-
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
322+
headers = {
323+
"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT),
324+
"Cache-Control": CACHE_CONTROL,
325+
"ETag": self._make_etag(repo_version),
326+
}
314327

315328
if media_type == PYPI_SIMPLE_V1_JSON:
316329
index_data = write_simple_index_json(names)
317330
return Response(index_data, headers=headers)
318331
else:
319-
index_data = write_simple_index(names, streamed=True)
332+
index_data = write_simple_index(names)
320333
kwargs = {"content_type": media_type, "headers": headers}
321-
return StreamingHttpResponse(index_data, **kwargs)
334+
return HttpResponse(index_data, **kwargs)
322335

323336
def pull_through_package_simple(self, package, path, remote):
324337
"""Gets the package's simple page from remote."""
@@ -355,14 +368,17 @@ def parse_package(release_package):
355368
}
356369

357370
@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
371+
@PythonApiCache(base_key=find_base_path_cached)
358372
def retrieve(self, request, path, package):
359373
"""Retrieves the simple api html/json page for a package."""
360374
repo_ver, content = self.get_rvc()
361375
# Should I redirect if the normalized name is different?
362376
normalized = canonicalize_name(package)
363377
releases = {}
378+
is_pull_through = False
364379
if self.distribution.remote:
365380
releases = self.pull_through_package_simple(normalized, path, self.distribution.remote)
381+
is_pull_through = True
366382
elif self.should_redirect(repo_version=repo_ver):
367383
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
368384
if content is not None:
@@ -405,7 +421,12 @@ def retrieve(self, request, path, package):
405421
return HttpResponseNotFound(f"{normalized} does not exist.")
406422

407423
media_type = request.accepted_renderer.media_type
408-
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT)}
424+
headers = {
425+
"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT),
426+
"Cache-Control": CACHE_CONTROL,
427+
}
428+
if not is_pull_through:
429+
headers["ETag"] = self._make_etag(repo_ver)
409430

410431
if media_type == PYPI_SIMPLE_V1_JSON:
411432
detail_data = write_simple_detail_json(normalized, releases.values())
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from urllib.parse import urljoin
2+
3+
import pytest
4+
import requests
5+
6+
from pulp_python.tests.functional.constants import (
7+
PYPI_SIMPLE_V1_HTML,
8+
PYPI_SIMPLE_V1_JSON,
9+
PYTHON_SM_PROJECT_SPECIFIER,
10+
)
11+
12+
13+
@pytest.fixture
14+
def skip_without_cache(pulp_settings):
15+
"""
16+
Skip test if server-side caching is not enabled.
17+
"""
18+
if not pulp_settings.CACHE_ENABLED:
19+
pytest.skip("CACHE_ENABLED is not set")
20+
21+
22+
@pytest.fixture
23+
def synced_distro(
24+
skip_without_cache,
25+
python_remote_factory,
26+
python_repo_with_sync,
27+
python_distribution_factory,
28+
):
29+
"""
30+
Sync a repo and create a distribution for cache tests.
31+
"""
32+
remote = python_remote_factory(includes=PYTHON_SM_PROJECT_SPECIFIER)
33+
repo = python_repo_with_sync(remote)
34+
return python_distribution_factory(repository=repo)
35+
36+
37+
@pytest.mark.parallel
38+
def test_simple_cache_hit_miss_and_headers(synced_distro):
39+
"""
40+
First request is a MISS, second is a HIT. Cache headers are present and stable.
41+
"""
42+
index_url = urljoin(synced_distro.base_url, "simple/")
43+
detail_url = f"{index_url}aiohttp"
44+
45+
for url in [index_url, detail_url]:
46+
r1 = requests.get(url)
47+
assert r1.status_code == 200
48+
assert r1.headers["X-PULP-CACHE"] == "MISS"
49+
assert r1.headers["Cache-Control"] == "max-age=600, public"
50+
assert r1.headers["ETag"].startswith('"') and r1.headers["ETag"].endswith('"')
51+
52+
r2 = requests.get(url)
53+
assert r2.status_code == 200
54+
assert r2.headers["X-PULP-CACHE"] == "HIT"
55+
assert r2.headers["Cache-Control"] == r1.headers["Cache-Control"]
56+
assert r2.headers["ETag"] == r1.headers["ETag"]
57+
58+
59+
@pytest.mark.parallel
60+
def test_simple_cache_separate_accept_headers(synced_distro):
61+
"""
62+
HTML and JSON responses are cached separately.
63+
"""
64+
url = urljoin(synced_distro.base_url, "simple/")
65+
66+
for header in [PYPI_SIMPLE_V1_HTML, PYPI_SIMPLE_V1_JSON]:
67+
r = requests.get(url, headers={"Accept": header})
68+
assert r.status_code == 200
69+
assert r.headers["X-PULP-CACHE"] == "MISS"
70+
71+
for header in [PYPI_SIMPLE_V1_HTML, PYPI_SIMPLE_V1_JSON]:
72+
r = requests.get(url, headers={"Accept": header})
73+
assert r.status_code == 200
74+
assert r.headers["X-PULP-CACHE"] == "HIT"
75+
76+
77+
@pytest.mark.parallel
78+
def test_simple_cache_etag_conditional_request(synced_distro):
79+
"""
80+
Matching If-None-Match returns 304, non-matching returns 200.
81+
"""
82+
url = urljoin(synced_distro.base_url, "simple/")
83+
84+
r1 = requests.get(url)
85+
assert r1.status_code == 200
86+
etag = r1.headers["ETag"]
87+
88+
r2 = requests.get(url, headers={"If-None-Match": etag})
89+
assert r2.status_code == 304
90+
assert r2.headers["ETag"] == etag
91+
assert len(r2.content) == 0
92+
93+
r3 = requests.get(url, headers={"If-None-Match": '"old"'})
94+
assert r3.status_code == 200
95+
assert r3.headers["ETag"] == etag
96+
assert len(r3.content) > 0

0 commit comments

Comments
 (0)