Skip to content

Commit b668056

Browse files
committed
rework etag
1 parent a70fe1d commit b668056

3 files changed

Lines changed: 37 additions & 41 deletions

File tree

pulp_python/app/cache.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.core.exceptions import ObjectDoesNotExist
2-
from django.http import Http404, HttpResponseNotModified
2+
from django.http import Http404
33

44
from pulpcore.plugin.cache import CacheKeys, SyncContentCache
55
from pulpcore.plugin.util import cache_key, get_domain
@@ -14,33 +14,12 @@ class PythonApiCache(SyncContentCache):
1414
Cache for the Simple API.
1515
1616
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).
1817
"""
1918

2019
def __init__(self, base_key=None):
2120
keys = (CacheKeys.path, CacheKeys.method, ACCEPT_HEADER_KEY)
2221
super().__init__(base_key=base_key, keys=keys)
2322

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-
4423
def make_key(self, request):
4524
all_keys = {
4625
CacheKeys.path: request.path,

pulp_python/app/pypi/views.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
HttpResponseNotFound,
1919
)
2020
from django.shortcuts import redirect
21+
from django.utils.decorators import method_decorator
22+
from django.views.decorators.http import condition
2123
from drf_spectacular.utils import extend_schema
2224
from dynaconf import settings
2325
from packaging.utils import canonicalize_name
@@ -67,6 +69,29 @@
6769
CACHE_CONTROL = "max-age=600, public"
6870

6971

72+
def _add_cache_control_to_304(func):
73+
"""Ensure 304 responses include Cache-Control."""
74+
75+
def wrapper(*args, **kwargs):
76+
response = func(*args, **kwargs)
77+
if response.status_code == 304:
78+
response["Cache-Control"] = CACHE_CONTROL
79+
return response
80+
81+
return wrapper
82+
83+
84+
def _etag_func(request, path, **kwargs):
85+
"""Compute unquoted ETag for the condition decorator. Returns None if no repo."""
86+
distro = PyPIMixin.get_distribution(path)
87+
try:
88+
repo_ver = PyPIMixin.get_repository_version(distro)
89+
except Http404:
90+
return None
91+
raw = f"{repo_ver.number}:{repo_ver.pulp_created.isoformat()}"
92+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
93+
94+
7095
class PyPISimpleHTMLRenderer(TemplateHTMLRenderer):
7196
media_type = PYPI_SIMPLE_V1_HTML
7297

@@ -299,13 +324,9 @@ def get_provenance_url(self, package, version, filename):
299324
self.base_api_url, f"{base_path}/integrity/{package}/{version}/{filename}/provenance/"
300325
)
301326

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-
308327
@extend_schema(summary="Get index simple page")
328+
@method_decorator(_add_cache_control_to_304)
329+
@method_decorator(condition(etag_func=_etag_func))
309330
@PythonApiCache(base_key=find_base_path_cached)
310331
def list(self, request, path):
311332
"""Gets the simple api html page for the index."""
@@ -319,11 +340,7 @@ def list(self, request, path):
319340
.iterator()
320341
)
321342
media_type = request.accepted_renderer.media_type
322-
headers = {
323-
"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT),
324-
"Cache-Control": CACHE_CONTROL,
325-
"ETag": self._make_etag(repo_version),
326-
}
343+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT), "Cache-Control": CACHE_CONTROL}
327344

328345
if media_type == PYPI_SIMPLE_V1_JSON:
329346
index_data = write_simple_index_json(names)
@@ -368,17 +385,17 @@ def parse_package(release_package):
368385
}
369386

370387
@extend_schema(operation_id="pypi_simple_package_read", summary="Get package simple page")
388+
@method_decorator(_add_cache_control_to_304)
389+
@method_decorator(condition(etag_func=_etag_func))
371390
@PythonApiCache(base_key=find_base_path_cached)
372391
def retrieve(self, request, path, package):
373392
"""Retrieves the simple api html/json page for a package."""
374393
repo_ver, content = self.get_rvc()
375394
# Should I redirect if the normalized name is different?
376395
normalized = canonicalize_name(package)
377396
releases = {}
378-
is_pull_through = False
379397
if self.distribution.remote:
380398
releases = self.pull_through_package_simple(normalized, path, self.distribution.remote)
381-
is_pull_through = True
382399
elif self.should_redirect(repo_version=repo_ver):
383400
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
384401
if content is not None:
@@ -421,12 +438,7 @@ def retrieve(self, request, path, package):
421438
return HttpResponseNotFound(f"{normalized} does not exist.")
422439

423440
media_type = request.accepted_renderer.media_type
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)
441+
headers = {"X-PyPI-Last-Serial": str(PYPI_SERIAL_CONSTANT), "Cache-Control": CACHE_CONTROL}
430442

431443
if media_type == PYPI_SIMPLE_V1_JSON:
432444
detail_data = write_simple_detail_json(normalized, releases.values())

pulp_python/tests/functional/api/test_simple_cache.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,18 @@ def test_simple_cache_etag_conditional_request(synced_distro):
8484
r1 = requests.get(url)
8585
assert r1.status_code == 200
8686
etag = r1.headers["ETag"]
87+
cache_control = r1.headers["Cache-Control"]
8788

8889
r2 = requests.get(url, headers={"If-None-Match": etag})
8990
assert r2.status_code == 304
9091
assert r2.headers["ETag"] == etag
92+
assert r2.headers["Cache-Control"] == cache_control
93+
assert "X-PULP-CACHE" not in r2.headers
9194
assert len(r2.content) == 0
9295

9396
r3 = requests.get(url, headers={"If-None-Match": '"old"'})
9497
assert r3.status_code == 200
9598
assert r3.headers["ETag"] == etag
99+
assert r3.headers["Cache-Control"] == cache_control
100+
assert r3.headers["X-PULP-CACHE"] == "HIT"
96101
assert len(r3.content) > 0

0 commit comments

Comments
 (0)