add builtin cache to requests wrapper (#80246) #68

Open
pmarillonnet wants to merge 1 commits from wip/80246-requests-wrapper-cached-results into main
3 changed files with 99 additions and 1 deletions

View File

@ -14,16 +14,26 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import logging
import urllib
from io import BytesIO
from django.conf import settings
from django.core.cache import cache
from django.utils.encoding import smart_bytes
from django.utils.http import urlencode
from requests import Response
from requests import Session as RequestsSession
from requests.auth import AuthBase
from hobo.signature import sign_url
class NothingInCacheException(Exception):
pass
class PublikSignature(AuthBase):
def __init__(self, secret):
self.secret = secret
@ -44,9 +54,17 @@ def get_known_service_for_url(url):
class Requests(RequestsSession):
def get_cache_key(self, url, params):
params = urlencode(params)
return hashlib.md5(smart_bytes(url + params)).hexdigest()
def request(self, method, url, **kwargs):
logger = logging.getLogger(__name__)
remote_service = get_known_service_for_url(url)
kwargs['auth'] = PublikSignature(remote_service.get('secret'))
cache_duration = kwargs.pop('cache_duration', 15)
invalidate_cache = kwargs.pop('invalidate_cache', False)
raise_if_not_cached = kwargs.pop('raise_if_not_cached', False)
# only keeps the path (URI) in url parameter, scheme and netloc are
# in remote_service
@ -61,4 +79,24 @@ class Requests(RequestsSession):
query = urlencode(query_params)
url = urllib.parse.urlunparse((scheme, netloc, path, params, query, fragment))
return super().request(method, url, **kwargs)
if method == 'GET' and cache_duration:
# handle cache
cache_key = self.get_cache_key(url=url, params=kwargs.get('params', {}))
cache_content = cache.get(cache_key)
if cache_content and not invalidate_cache:
response = Response()
response.status_code = 200
response.raw = BytesIO(smart_bytes(cache_content))
return response
elif raise_if_not_cached:
raise NothingInCacheException()
nroche marked this conversation as resolved Outdated

ligne 93: Tu sais à quoi correspond ce 'auto' ?
ligne 96: Le code d'origine gérait déjà remote_service et kwargs['auth'].

Dans combo le 'auto' provient d'un paramètre passé à request quand on attend une signature.
Bref j'ai l'impression qu'il y a redondance à la ligne 94
et retrait de la signature lorsque l'on ne va pas vers un service (ligne 96), ce qui s'entend.
Peut-être ajouter un test là-dessus pour être plus explicite ?

ligne 93: Tu sais à quoi correspond ce 'auto' ? ligne 96: Le code d'origine gérait déjà remote_service et kwargs['auth']. Dans combo le 'auto' provient d'un paramètre passé à request quand on attend une signature. Bref j'ai l'impression qu'il y a redondance à la ligne 94 et retrait de la signature lorsque l'on ne va pas vers un service (ligne 96), ce qui s'entend. Peut-être ajouter un test là-dessus pour être plus explicite ?

Oui tu as raison, j’ai viré tout cela, et ai laissé cette partie là du code dans sa version d’origine.

Oui tu as raison, j’ai viré tout cela, et ai laissé cette partie là du code dans sa version d’origine.
kwargs['timeout'] = kwargs.get('timeout') or settings.REQUESTS_TIMEOUT
response = super().request(method, url, **kwargs)
if response.status_code // 100 != 2:
logger.warning('failed to %s %s (%s)', method, response.request.url, response.status_code)
if method == 'GET' and cache_duration and (response.status_code // 100 == 2):
cache.set(cache_key, response.content, cache_duration)
return response

View File

@ -198,6 +198,10 @@ LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/'
LOGOUT_URL = '/logout/'
# timeout used in python-requests call, in seconds
# we use 28s by default: timeout just before web server, which is usually 30s
REQUESTS_TIMEOUT = 28
# mellon authentication params
MELLON_ADAPTER = ('hobo.utils.MellonAdapter',)

56
tests/test_requests.py Normal file
View File

@ -0,0 +1,56 @@
from unittest import mock
import pytest
from hobo.requests_wrapper import NothingInCacheException, Requests
def test_requests_cache(settings):
settings.KNOWN_SERVICES = {
'chrono': {
'foobar': {
'title': 'Foo',
'url': 'https://chrono.example.com/',
'orig': 'example.com',
'secret': 'xxx',
}
},
'hobo': {
'hobo': {
'title': 'Hobo',
'url': 'https://hobo.example.com/',
'orig': 'example.com',
'secret': 'xxx',
}
},
}
with mock.patch('hobo.requests_wrapper.RequestsSession.request') as requests_get:
requests = Requests()
requests_get.return_value = mock.Mock(content=b'hello world', status_code=200)
# default cache, nothing in there
assert requests.get('http://chrono.example.com/').content == b'hello world'
assert requests_get.call_count == 1
# now there's something in cache
assert requests.get('http://chrono.example.com/').content == b'hello world'
assert requests_get.call_count == 1
# passing parameters triggers new request
assert requests.get('http://chrono.example.com/', params={'test': 'test'}).content == b'hello world'
assert requests_get.call_count == 2
# if parameters are the same, cache is used
assert requests.get('http://chrono.example.com/', params={'test': 'test'}).content == b'hello world'
assert requests_get.call_count == 2
# value changed
requests_get.return_value = mock.Mock(content=b'hello second world', status_code=200)
assert requests.get('http://chrono.example.com/').content == b'hello world'
assert requests_get.call_count == 2
# force cache invalidation
assert (
requests.get('http://chrono.example.com/', invalidate_cache=True).content == b'hello second world'
)
assert requests_get.call_count == 3
# check raise_if_not_cached
with pytest.raises(NothingInCacheException):
requests.get('http://chrono.example.com/other', raise_if_not_cached=True)
# check with unicode url
assert requests.get('http://chrono.example.com/éléphant').content == b'hello second world'