Add caching.cached_endpoint decorator for 304'ing any url.
This commit is contained in:
parent
d740e6d686
commit
5ac3a8a121
2 changed files with 111 additions and 41 deletions
|
@ -1,23 +1,6 @@
|
||||||
'''
|
import flask; from flask import request
|
||||||
This file provides the FileCacheManager to serve ETag and Cache-Control headers
|
import functools
|
||||||
for files on disk.
|
import hashlib
|
||||||
|
|
||||||
We consider the following cases:
|
|
||||||
|
|
||||||
Client does not have the file (or has disabled their cache, effectively same):
|
|
||||||
Server sends file, provides ETag, and tells client to save it for max-age.
|
|
||||||
|
|
||||||
Client has the file, but it has been a long time, beyond the max-age:
|
|
||||||
Client provides the old ETag. If it's still valid, Server responds with
|
|
||||||
304 Not Modified and no data. Client keeps the file.
|
|
||||||
|
|
||||||
Client has the file, and it is within the max-age:
|
|
||||||
Client does not make a request at all.
|
|
||||||
|
|
||||||
This FileCacheManager uses the file's MD5 hash as the ETag, and will only
|
|
||||||
recalculate it if the file's mtime has changed since the last request.
|
|
||||||
'''
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import etiquette
|
import etiquette
|
||||||
|
@ -25,42 +8,130 @@ import etiquette
|
||||||
from voussoirkit import cacheclass
|
from voussoirkit import cacheclass
|
||||||
from voussoirkit import pathclass
|
from voussoirkit import pathclass
|
||||||
|
|
||||||
|
|
||||||
|
def cached_endpoint(max_age):
|
||||||
|
'''
|
||||||
|
The cached_endpoint decorator can be used on slow endpoints that don't need
|
||||||
|
to be constantly updated or endpoints that produce large, static responses.
|
||||||
|
|
||||||
|
WARNING: The return value of the endpoint is shared with all users.
|
||||||
|
You should never use this cache on an endpoint that provides private
|
||||||
|
or personalized data, and you should not try to pass other headers through
|
||||||
|
the response.
|
||||||
|
|
||||||
|
When the function is run, its return value is stored and a random etag is
|
||||||
|
generated so that subsequent runs can respond with 304. This way, large
|
||||||
|
response bodies do not need to be transmitted often.
|
||||||
|
|
||||||
|
Given a nonzero max_age, the endpoint will only be run once per max_age
|
||||||
|
seconds on a global basis (not per-user). This way, you can prevent a slow
|
||||||
|
function from being run very often. In-between requests will just receive
|
||||||
|
the previous return value (still using 200 or 304 as appropriate for the
|
||||||
|
client's provided etag).
|
||||||
|
|
||||||
|
An example use case would be large-sized data dumps that don't need to be
|
||||||
|
precisely up to date every time.
|
||||||
|
'''
|
||||||
|
state = {
|
||||||
|
'max_age': max_age,
|
||||||
|
'stored_value': None,
|
||||||
|
'stored_etag': None,
|
||||||
|
'headers': {'ETag': None, 'Cache-Control': f'max-age={max_age}'},
|
||||||
|
'last_run': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def wrapper(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if (not state['max_age']) or (time.time() - state['last_run'] > state['max_age']):
|
||||||
|
value = function(*args, **kwargs)
|
||||||
|
if isinstance(value, flask.Response):
|
||||||
|
value = value.response
|
||||||
|
if value != state['stored_value']:
|
||||||
|
state['stored_value'] = value
|
||||||
|
state['stored_etag'] = etiquette.helpers.random_hex(20)
|
||||||
|
state['headers']['ETag'] = state['stored_etag']
|
||||||
|
state['last_run'] = time.time()
|
||||||
|
else:
|
||||||
|
value = state['stored_value']
|
||||||
|
|
||||||
|
client_etag = request.headers.get('If-None-Match', None)
|
||||||
|
if client_etag == state['stored_etag']:
|
||||||
|
response = flask.Response(status=304, headers=state['headers'])
|
||||||
|
else:
|
||||||
|
response = flask.Response(value, status=200, headers=state['headers'])
|
||||||
|
|
||||||
|
return response
|
||||||
|
return wrapped
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class FileCacheManager:
|
class FileCacheManager:
|
||||||
def __init__(self, maxlen, max_filesize, max_age):
|
'''
|
||||||
|
The FileCacheManager serves ETag and Cache-Control headers for disk files.
|
||||||
|
|
||||||
|
We consider the following cases:
|
||||||
|
|
||||||
|
Client does not have the file (or has disabled their cache):
|
||||||
|
Server sends file, provides ETag, tells client to save it for max-age.
|
||||||
|
|
||||||
|
Client has the file, but it has been a long time, beyond the max-age:
|
||||||
|
Client provides the old ETag. If it's still valid, Server responds with
|
||||||
|
304 Not Modified and no data. Client keeps the old file.
|
||||||
|
|
||||||
|
Client has the file, and it is within the max-age:
|
||||||
|
Client does not make a request at all.
|
||||||
|
|
||||||
|
We use the file's MD5 hash as the ETag, and will only recalculate it if the
|
||||||
|
file's mtime has changed since the last request.
|
||||||
|
'''
|
||||||
|
def __init__(self, maxlen, max_age, max_filesize):
|
||||||
self.cache = cacheclass.Cache(maxlen=maxlen)
|
self.cache = cacheclass.Cache(maxlen=maxlen)
|
||||||
self.max_filesize = int(max_filesize)
|
|
||||||
self.max_age = int(max_age)
|
self.max_age = int(max_age)
|
||||||
|
self.max_filesize = max(int(max_filesize), 0) or None
|
||||||
|
|
||||||
def get(self, filepath):
|
def get(self, filepath):
|
||||||
if (self.max_filesize is not None) and (filepath.size > self.max_filesize):
|
|
||||||
#print('I\'m not going to cache that!')
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return self.cache[filepath]
|
return self.cache[filepath]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if (self.max_filesize is not None) and (filepath.size > self.max_filesize):
|
||||||
|
return None
|
||||||
|
|
||||||
cache_file = CacheFile(filepath, max_age=self.max_age)
|
cache_file = CacheFile(filepath, max_age=self.max_age)
|
||||||
self.cache[filepath] = cache_file
|
self.cache[filepath] = cache_file
|
||||||
return cache_file
|
return cache_file
|
||||||
|
|
||||||
|
def matches(self, request, filepath):
|
||||||
|
client_etag = request.headers.get('If-None-Match', None)
|
||||||
|
if client_etag is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_value = self.get(filepath)
|
||||||
|
if server_value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_etag = server_value.get_etag()
|
||||||
|
if client_etag != server_etag:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return server_value.get_headers()
|
||||||
|
|
||||||
|
|
||||||
class CacheFile:
|
class CacheFile:
|
||||||
def __init__(self, filepath, max_age):
|
def __init__(self, filepath, max_age):
|
||||||
self.filepath = filepath
|
self.filepath = filepath
|
||||||
self.max_age = max_age
|
self.max_age = int(max_age)
|
||||||
self._stored_hash_time = None
|
self._stored_hash_time = None
|
||||||
self._stored_hash_value = None
|
self._stored_hash_value = None
|
||||||
|
|
||||||
def get_etag(self):
|
def get_etag(self):
|
||||||
if self._stored_hash_value is None:
|
mtime = self.filepath.stat.st_mtime
|
||||||
refresh = True
|
do_refresh = (self._stored_hash_value is None) or (mtime > self._stored_hash_time)
|
||||||
elif self.filepath.stat.st_mtime > self._stored_hash_time:
|
|
||||||
refresh = True
|
|
||||||
else:
|
|
||||||
refresh = False
|
|
||||||
|
|
||||||
if refresh:
|
if do_refresh:
|
||||||
self._stored_hash_time = self.filepath.stat.st_mtime
|
self._stored_hash_time = mtime
|
||||||
self._stored_hash_value = etiquette.helpers.hash_file_md5(self.filepath)
|
self._stored_hash_value = etiquette.helpers.hash_file_md5(self.filepath)
|
||||||
return self._stored_hash_value
|
return self._stored_hash_value
|
||||||
|
|
||||||
|
|
|
@ -120,11 +120,9 @@ def send_file(filepath, override_mimetype=None):
|
||||||
if not filepath.is_file:
|
if not filepath.is_file:
|
||||||
flask.abort(404)
|
flask.abort(404)
|
||||||
|
|
||||||
cache_file = file_cache_manager.get(filepath)
|
headers = file_cache_manager.matches(request=request, filepath=filepath)
|
||||||
if cache_file is not None:
|
if headers:
|
||||||
client_etag = request.headers.get('If-None-Match', None)
|
response = flask.Response(status=304, headers=headers)
|
||||||
if client_etag and client_etag == cache_file.get_etag():
|
|
||||||
response = flask.Response(status=304, headers=cache_file.get_headers())
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
outgoing_headers = {}
|
outgoing_headers = {}
|
||||||
|
@ -174,6 +172,7 @@ def send_file(filepath, override_mimetype=None):
|
||||||
|
|
||||||
outgoing_headers['Accept-Ranges'] = 'bytes'
|
outgoing_headers['Accept-Ranges'] = 'bytes'
|
||||||
outgoing_headers['Content-Length'] = (range_max - range_min) + 1
|
outgoing_headers['Content-Length'] = (range_max - range_min) + 1
|
||||||
|
cache_file = file_cache_manager.get(filepath)
|
||||||
if cache_file is not None:
|
if cache_file is not None:
|
||||||
outgoing_headers.update(cache_file.get_headers())
|
outgoing_headers.update(cache_file.get_headers())
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue