44from functools import wraps
55
66from aiohttp .web import FileResponse , HTTPSuccessful , Request , Response , StreamResponse
7- from aiohttp .web_exceptions import HTTPFound
7+ from aiohttp .web_exceptions import HTTPFound , HTTPNotModified
88from django .conf import settings
99from django .http import FileResponse as ApiFileResponse
1010from django .http import HttpResponse , HttpResponseRedirect
11+ from django .utils .http import parse_http_date_safe
1112from redis import ConnectionError
1213from redis .asyncio import ConnectionError as AConnectionError
1314from rest_framework .request import Request as ApiRequest
1819 get_redis_connection ,
1920)
2021from pulpcore .metrics import artifacts_size_counter
21- from pulpcore .responses import ArtifactResponse
22+ from pulpcore .responses import ArtifactResponse , PulpFileResponse
2223
2324DEFAULT_EXPIRES_TTL = settings .CACHE_SETTINGS ["EXPIRES_TTL" ]
2425
@@ -306,7 +307,7 @@ class AsyncContentCache(AsyncCache):
306307 """Cache object meant to be used for the content app"""
307308
308309 RESPONSE_TYPES = {
309- "FileResponse" : FileResponse ,
310+ "FileResponse" : PulpFileResponse ,
310311 "ArtifactResponse" : ArtifactResponse ,
311312 "Response" : Response ,
312313 "Redirect" : HTTPFound ,
@@ -349,32 +350,93 @@ async def cached_function(*args, **kwargs):
349350 if self .auth :
350351 await self .auth (request , self , bk )
351352 key = self .make_key (request )
353+
352354 # Check cache
353- response = await self .make_response (key , bk )
354- if response is None :
355- # Cache miss, create new entry
356- response = await self .make_entry (
357- key , bk , func , args , kwargs , self .default_expires_ttl
355+ entry = await self .get_entry (key , bk )
356+ if entry is not None :
357+ # Cache hit. Authorization has already run. If the client's If-Modified-Since
358+ # covers the stored last_modified, answer a bodyless 304 without reconstructing
359+ # the full response. Fall back to the header for entries cached before this field.
360+ last_modified = entry .get ("last_modified" ) or entry .get ("headers" , {}).get (
361+ "Last-Modified"
358362 )
359- elif size := response .headers .get ("X-PULP-ARTIFACT-SIZE" ):
360- artifacts_size_counter .add (size )
361-
363+ if self ._not_modified (request , last_modified ):
364+ headers = dict (entry .get ("headers" ) or {})
365+ headers ["X-PULP-CACHE" ] = "HIT"
366+ raise self ._make_not_modified (headers , last_modified )
367+ response = self .build_response (entry )
368+ if size := response .headers .get ("X-PULP-ARTIFACT-SIZE" ):
369+ artifacts_size_counter .add (size )
370+ return response
371+
372+ # Cache miss: build and cache the full response (a 304 is never stored). Still answer
373+ # a matching conditional request with a 304 from the fresh response's Last-Modified,
374+ # but never after a stream has already started writing.
375+ response = await self .make_entry (key , bk , func , args , kwargs , self .default_expires_ttl )
376+ if getattr (response , "prepared" , False ):
377+ return response
378+ last_modified = response .headers .get ("Last-Modified" )
379+ if self ._not_modified (request , last_modified ):
380+ raise self ._make_not_modified (response .headers , last_modified )
362381 return response
363382
364383 return cached_function
365384
385+ @staticmethod
386+ def _not_modified (request , last_modified ):
387+ """True when the request's If-Modified-Since covers the given Last-Modified value.
388+
389+ Ignore If-Modified-Since when If-None-Match is present, or when it is later than
390+ the server clock.
391+ """
392+ if not last_modified :
393+ return False
394+ if request .headers .get ("If-None-Match" ):
395+ return False
396+ if_modified_since = parse_http_date_safe (request .headers .get ("If-Modified-Since" , "" ))
397+ if if_modified_since is None or if_modified_since > time .time ():
398+ return False
399+ lm_epoch = parse_http_date_safe (last_modified )
400+ return lm_epoch is not None and lm_epoch <= if_modified_since
401+
402+ @staticmethod
403+ def _make_not_modified (source_headers , last_modified ):
404+ """Build a bodyless 304 echoing Last-Modified and any caching metadata already present."""
405+ headers = {"Last-Modified" : last_modified }
406+ for name in ("Cache-Control" , "X-PULP-CACHE" ):
407+ if value := source_headers .get (name ):
408+ headers [name ] = value
409+ return HTTPNotModified (headers = headers )
410+
366411 def get_request_from_args (self , args ):
367412 """Finds the request object from list of args"""
368413 for arg in args :
369414 if isinstance (arg , Request ):
370415 return arg
371416
372- async def make_response (self , key , base_key ):
373- """Tries to find the cached entry and turn it into a proper response """
417+ async def get_entry (self , key , base_key ):
418+ """Return the cached entry dict for ``key`` (deleting stale/invalid rows), or None. """
374419 entry = await self .get (key , base_key )
375420 if not entry :
376421 return None
377422 entry = json .loads (entry )
423+ response_type = entry .get ("type" )
424+ # None means "doesn't expire", unset/absent means "already expired".
425+ expires = entry .get ("expires" , - 1 )
426+ if (not response_type or response_type not in self .RESPONSE_TYPES ) or (
427+ expires and expires < time .time ()
428+ ):
429+ # Bad entry, delete from cache
430+ await self .delete (key , base_key )
431+ return None
432+ return entry
433+
434+ def build_response (self , entry ):
435+ """Turn a cached entry dict into a proper response object (marked as a cache HIT)."""
436+ entry = dict (entry ) # do not mutate the caller's dict
437+ entry .pop ("expires" , None )
438+ entry .pop ("last_modified" , None )
439+ response_type = entry .pop ("type" )
378440
379441 if binary := entry .pop ("body" , None ):
380442 # raw binary data were translated to their hexadecimal representation and saved in
@@ -383,23 +445,24 @@ async def make_response(self, key, base_key):
383445 # https://docs.aiohttp.org/en/stable/web_reference.html#response
384446 entry ["body" ] = bytes .fromhex (binary )
385447
386- response_type = entry .pop ("type" , None )
387- # None means "doesn't expire", unset means "already expired".
388- expires = entry .pop ("expires" , - 1 )
389- if (not response_type or response_type not in self .RESPONSE_TYPES ) or (
390- expires and expires < time .time ()
391- ):
392- # Bad entry, delete from cache
393- await self .delete (key , base_key )
394- return None
395448 response = self .RESPONSE_TYPES [response_type ](** entry )
396449 response .headers .update ({"X-PULP-CACHE" : "HIT" })
397450 return response
398451
452+ async def make_response (self , key , base_key ):
453+ """Tries to find the cached entry and turn it into a proper response"""
454+ entry = await self .get_entry (key , base_key )
455+ if entry is None :
456+ return None
457+ return self .build_response (entry )
458+
399459 async def make_entry (self , key , base_key , handler , args , kwargs , expires = DEFAULT_EXPIRES_TTL ):
400460 """Gets the response for the request and try to turn it into a cacheable entry"""
401461 try :
402462 response = await handler (* args , ** kwargs )
463+ except HTTPNotModified :
464+ # HTTPNotModified is HTTPSuccessful; do not swallow it into a cached entry.
465+ raise
403466 except (HTTPSuccessful , HTTPFound ) as e :
404467 response = e
405468
@@ -408,7 +471,13 @@ async def make_entry(self, key, base_key, handler, args, kwargs, expires=DEFAULT
408471 if hasattr (response , "future_response" ):
409472 response = response .future_response
410473
474+ if getattr (response , "status" , None ) == 304 :
475+ return original_response
476+
411477 entry = {"headers" : dict (response .headers ), "status" : response .status }
478+ if last_modified := response .headers .get ("Last-Modified" ):
479+ # Stored alongside headers so a cache hit can 304 without reconstructing the response.
480+ entry ["last_modified" ] = last_modified
412481 if expires is not None :
413482 # Redis TTL is not sufficient: https://github.com/pulp/pulpcore/issues/4845
414483 entry ["expires" ] = expires + time .time ()
0 commit comments