controller.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. # SPDX-FileCopyrightText: 2015 Eric Larson
  2. #
  3. # SPDX-License-Identifier: Apache-2.0
  4. """
  5. The httplib2 algorithms ported for use with requests.
  6. """
  7. from __future__ import annotations
  8. import calendar
  9. import logging
  10. import re
  11. import time
  12. import weakref
  13. from email.utils import parsedate_tz
  14. from typing import TYPE_CHECKING, Collection, Mapping
  15. from pip._vendor.requests.structures import CaseInsensitiveDict
  16. from pip._vendor.cachecontrol.cache import DictCache, SeparateBodyBaseCache
  17. from pip._vendor.cachecontrol.serialize import Serializer
  18. if TYPE_CHECKING:
  19. from typing import Literal
  20. from pip._vendor.requests import PreparedRequest
  21. from pip._vendor.urllib3 import HTTPResponse
  22. from pip._vendor.cachecontrol.cache import BaseCache
  23. logger = logging.getLogger(__name__)
  24. URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?")
  25. PERMANENT_REDIRECT_STATUSES = (301, 308)
  26. def parse_uri(uri: str) -> tuple[str, str, str, str, str]:
  27. """Parses a URI using the regex given in Appendix B of RFC 3986.
  28. (scheme, authority, path, query, fragment) = parse_uri(uri)
  29. """
  30. match = URI.match(uri)
  31. assert match is not None
  32. groups = match.groups()
  33. return (groups[1], groups[3], groups[4], groups[6], groups[8])
  34. class CacheController:
  35. """An interface to see if request should cached or not."""
  36. def __init__(
  37. self,
  38. cache: BaseCache | None = None,
  39. cache_etags: bool = True,
  40. serializer: Serializer | None = None,
  41. status_codes: Collection[int] | None = None,
  42. ):
  43. self.cache = DictCache() if cache is None else cache
  44. self.cache_etags = cache_etags
  45. self.serializer = serializer or Serializer()
  46. self.cacheable_status_codes = status_codes or (200, 203, 300, 301, 308)
  47. @classmethod
  48. def _urlnorm(cls, uri: str) -> str:
  49. """Normalize the URL to create a safe key for the cache"""
  50. (scheme, authority, path, query, fragment) = parse_uri(uri)
  51. if not scheme or not authority:
  52. raise Exception("Only absolute URIs are allowed. uri = %s" % uri)
  53. scheme = scheme.lower()
  54. authority = authority.lower()
  55. if not path:
  56. path = "/"
  57. # Could do syntax based normalization of the URI before
  58. # computing the digest. See Section 6.2.2 of Std 66.
  59. request_uri = query and "?".join([path, query]) or path
  60. defrag_uri = scheme + "://" + authority + request_uri
  61. return defrag_uri
  62. @classmethod
  63. def cache_url(cls, uri: str) -> str:
  64. return cls._urlnorm(uri)
  65. def parse_cache_control(self, headers: Mapping[str, str]) -> dict[str, int | None]:
  66. known_directives = {
  67. # https://tools.ietf.org/html/rfc7234#section-5.2
  68. "max-age": (int, True),
  69. "max-stale": (int, False),
  70. "min-fresh": (int, True),
  71. "no-cache": (None, False),
  72. "no-store": (None, False),
  73. "no-transform": (None, False),
  74. "only-if-cached": (None, False),
  75. "must-revalidate": (None, False),
  76. "public": (None, False),
  77. "private": (None, False),
  78. "proxy-revalidate": (None, False),
  79. "s-maxage": (int, True),
  80. }
  81. cc_headers = headers.get("cache-control", headers.get("Cache-Control", ""))
  82. retval: dict[str, int | None] = {}
  83. for cc_directive in cc_headers.split(","):
  84. if not cc_directive.strip():
  85. continue
  86. parts = cc_directive.split("=", 1)
  87. directive = parts[0].strip()
  88. try:
  89. typ, required = known_directives[directive]
  90. except KeyError:
  91. logger.debug("Ignoring unknown cache-control directive: %s", directive)
  92. continue
  93. if not typ or not required:
  94. retval[directive] = None
  95. if typ:
  96. try:
  97. retval[directive] = typ(parts[1].strip())
  98. except IndexError:
  99. if required:
  100. logger.debug(
  101. "Missing value for cache-control " "directive: %s",
  102. directive,
  103. )
  104. except ValueError:
  105. logger.debug(
  106. "Invalid value for cache-control directive " "%s, must be %s",
  107. directive,
  108. typ.__name__,
  109. )
  110. return retval
  111. def _load_from_cache(self, request: PreparedRequest) -> HTTPResponse | None:
  112. """
  113. Load a cached response, or return None if it's not available.
  114. """
  115. # We do not support caching of partial content: so if the request contains a
  116. # Range header then we don't want to load anything from the cache.
  117. if "Range" in request.headers:
  118. return None
  119. cache_url = request.url
  120. assert cache_url is not None
  121. cache_data = self.cache.get(cache_url)
  122. if cache_data is None:
  123. logger.debug("No cache entry available")
  124. return None
  125. if isinstance(self.cache, SeparateBodyBaseCache):
  126. body_file = self.cache.get_body(cache_url)
  127. else:
  128. body_file = None
  129. result = self.serializer.loads(request, cache_data, body_file)
  130. if result is None:
  131. logger.warning("Cache entry deserialization failed, entry ignored")
  132. return result
  133. def cached_request(self, request: PreparedRequest) -> HTTPResponse | Literal[False]:
  134. """
  135. Return a cached response if it exists in the cache, otherwise
  136. return False.
  137. """
  138. assert request.url is not None
  139. cache_url = self.cache_url(request.url)
  140. logger.debug('Looking up "%s" in the cache', cache_url)
  141. cc = self.parse_cache_control(request.headers)
  142. # Bail out if the request insists on fresh data
  143. if "no-cache" in cc:
  144. logger.debug('Request header has "no-cache", cache bypassed')
  145. return False
  146. if "max-age" in cc and cc["max-age"] == 0:
  147. logger.debug('Request header has "max_age" as 0, cache bypassed')
  148. return False
  149. # Check whether we can load the response from the cache:
  150. resp = self._load_from_cache(request)
  151. if not resp:
  152. return False
  153. # If we have a cached permanent redirect, return it immediately. We
  154. # don't need to test our response for other headers b/c it is
  155. # intrinsically "cacheable" as it is Permanent.
  156. #
  157. # See:
  158. # https://tools.ietf.org/html/rfc7231#section-6.4.2
  159. #
  160. # Client can try to refresh the value by repeating the request
  161. # with cache busting headers as usual (ie no-cache).
  162. if int(resp.status) in PERMANENT_REDIRECT_STATUSES:
  163. msg = (
  164. "Returning cached permanent redirect response "
  165. "(ignoring date and etag information)"
  166. )
  167. logger.debug(msg)
  168. return resp
  169. headers: CaseInsensitiveDict[str] = CaseInsensitiveDict(resp.headers)
  170. if not headers or "date" not in headers:
  171. if "etag" not in headers:
  172. # Without date or etag, the cached response can never be used
  173. # and should be deleted.
  174. logger.debug("Purging cached response: no date or etag")
  175. self.cache.delete(cache_url)
  176. logger.debug("Ignoring cached response: no date")
  177. return False
  178. now = time.time()
  179. time_tuple = parsedate_tz(headers["date"])
  180. assert time_tuple is not None
  181. date = calendar.timegm(time_tuple[:6])
  182. current_age = max(0, now - date)
  183. logger.debug("Current age based on date: %i", current_age)
  184. # TODO: There is an assumption that the result will be a
  185. # urllib3 response object. This may not be best since we
  186. # could probably avoid instantiating or constructing the
  187. # response until we know we need it.
  188. resp_cc = self.parse_cache_control(headers)
  189. # determine freshness
  190. freshness_lifetime = 0
  191. # Check the max-age pragma in the cache control header
  192. max_age = resp_cc.get("max-age")
  193. if max_age is not None:
  194. freshness_lifetime = max_age
  195. logger.debug("Freshness lifetime from max-age: %i", freshness_lifetime)
  196. # If there isn't a max-age, check for an expires header
  197. elif "expires" in headers:
  198. expires = parsedate_tz(headers["expires"])
  199. if expires is not None:
  200. expire_time = calendar.timegm(expires[:6]) - date
  201. freshness_lifetime = max(0, expire_time)
  202. logger.debug("Freshness lifetime from expires: %i", freshness_lifetime)
  203. # Determine if we are setting freshness limit in the
  204. # request. Note, this overrides what was in the response.
  205. max_age = cc.get("max-age")
  206. if max_age is not None:
  207. freshness_lifetime = max_age
  208. logger.debug(
  209. "Freshness lifetime from request max-age: %i", freshness_lifetime
  210. )
  211. min_fresh = cc.get("min-fresh")
  212. if min_fresh is not None:
  213. # adjust our current age by our min fresh
  214. current_age += min_fresh
  215. logger.debug("Adjusted current age from min-fresh: %i", current_age)
  216. # Return entry if it is fresh enough
  217. if freshness_lifetime > current_age:
  218. logger.debug('The response is "fresh", returning cached response')
  219. logger.debug("%i > %i", freshness_lifetime, current_age)
  220. return resp
  221. # we're not fresh. If we don't have an Etag, clear it out
  222. if "etag" not in headers:
  223. logger.debug('The cached response is "stale" with no etag, purging')
  224. self.cache.delete(cache_url)
  225. # return the original handler
  226. return False
  227. def conditional_headers(self, request: PreparedRequest) -> dict[str, str]:
  228. resp = self._load_from_cache(request)
  229. new_headers = {}
  230. if resp:
  231. headers: CaseInsensitiveDict[str] = CaseInsensitiveDict(resp.headers)
  232. if "etag" in headers:
  233. new_headers["If-None-Match"] = headers["ETag"]
  234. if "last-modified" in headers:
  235. new_headers["If-Modified-Since"] = headers["Last-Modified"]
  236. return new_headers
  237. def _cache_set(
  238. self,
  239. cache_url: str,
  240. request: PreparedRequest,
  241. response: HTTPResponse,
  242. body: bytes | None = None,
  243. expires_time: int | None = None,
  244. ) -> None:
  245. """
  246. Store the data in the cache.
  247. """
  248. if isinstance(self.cache, SeparateBodyBaseCache):
  249. # We pass in the body separately; just put a placeholder empty
  250. # string in the metadata.
  251. self.cache.set(
  252. cache_url,
  253. self.serializer.dumps(request, response, b""),
  254. expires=expires_time,
  255. )
  256. # body is None can happen when, for example, we're only updating
  257. # headers, as is the case in update_cached_response().
  258. if body is not None:
  259. self.cache.set_body(cache_url, body)
  260. else:
  261. self.cache.set(
  262. cache_url,
  263. self.serializer.dumps(request, response, body),
  264. expires=expires_time,
  265. )
  266. def cache_response(
  267. self,
  268. request: PreparedRequest,
  269. response_or_ref: HTTPResponse | weakref.ReferenceType[HTTPResponse],
  270. body: bytes | None = None,
  271. status_codes: Collection[int] | None = None,
  272. ) -> None:
  273. """
  274. Algorithm for caching requests.
  275. This assumes a requests Response object.
  276. """
  277. if isinstance(response_or_ref, weakref.ReferenceType):
  278. response = response_or_ref()
  279. if response is None:
  280. # The weakref can be None only in case the user used streamed request
  281. # and did not consume or close it, and holds no reference to requests.Response.
  282. # In such case, we don't want to cache the response.
  283. return
  284. else:
  285. response = response_or_ref
  286. # From httplib2: Don't cache 206's since we aren't going to
  287. # handle byte range requests
  288. cacheable_status_codes = status_codes or self.cacheable_status_codes
  289. if response.status not in cacheable_status_codes:
  290. logger.debug(
  291. "Status code %s not in %s", response.status, cacheable_status_codes
  292. )
  293. return
  294. response_headers: CaseInsensitiveDict[str] = CaseInsensitiveDict(
  295. response.headers
  296. )
  297. if "date" in response_headers:
  298. time_tuple = parsedate_tz(response_headers["date"])
  299. assert time_tuple is not None
  300. date = calendar.timegm(time_tuple[:6])
  301. else:
  302. date = 0
  303. # If we've been given a body, our response has a Content-Length, that
  304. # Content-Length is valid then we can check to see if the body we've
  305. # been given matches the expected size, and if it doesn't we'll just
  306. # skip trying to cache it.
  307. if (
  308. body is not None
  309. and "content-length" in response_headers
  310. and response_headers["content-length"].isdigit()
  311. and int(response_headers["content-length"]) != len(body)
  312. ):
  313. return
  314. cc_req = self.parse_cache_control(request.headers)
  315. cc = self.parse_cache_control(response_headers)
  316. assert request.url is not None
  317. cache_url = self.cache_url(request.url)
  318. logger.debug('Updating cache with response from "%s"', cache_url)
  319. # Delete it from the cache if we happen to have it stored there
  320. no_store = False
  321. if "no-store" in cc:
  322. no_store = True
  323. logger.debug('Response header has "no-store"')
  324. if "no-store" in cc_req:
  325. no_store = True
  326. logger.debug('Request header has "no-store"')
  327. if no_store and self.cache.get(cache_url):
  328. logger.debug('Purging existing cache entry to honor "no-store"')
  329. self.cache.delete(cache_url)
  330. if no_store:
  331. return
  332. # https://tools.ietf.org/html/rfc7234#section-4.1:
  333. # A Vary header field-value of "*" always fails to match.
  334. # Storing such a response leads to a deserialization warning
  335. # during cache lookup and is not allowed to ever be served,
  336. # so storing it can be avoided.
  337. if "*" in response_headers.get("vary", ""):
  338. logger.debug('Response header has "Vary: *"')
  339. return
  340. # If we've been given an etag, then keep the response
  341. if self.cache_etags and "etag" in response_headers:
  342. expires_time = 0
  343. if response_headers.get("expires"):
  344. expires = parsedate_tz(response_headers["expires"])
  345. if expires is not None:
  346. expires_time = calendar.timegm(expires[:6]) - date
  347. expires_time = max(expires_time, 14 * 86400)
  348. logger.debug(f"etag object cached for {expires_time} seconds")
  349. logger.debug("Caching due to etag")
  350. self._cache_set(cache_url, request, response, body, expires_time)
  351. # Add to the cache any permanent redirects. We do this before looking
  352. # that the Date headers.
  353. elif int(response.status) in PERMANENT_REDIRECT_STATUSES:
  354. logger.debug("Caching permanent redirect")
  355. self._cache_set(cache_url, request, response, b"")
  356. # Add to the cache if the response headers demand it. If there
  357. # is no date header then we can't do anything about expiring
  358. # the cache.
  359. elif "date" in response_headers:
  360. time_tuple = parsedate_tz(response_headers["date"])
  361. assert time_tuple is not None
  362. date = calendar.timegm(time_tuple[:6])
  363. # cache when there is a max-age > 0
  364. max_age = cc.get("max-age")
  365. if max_age is not None and max_age > 0:
  366. logger.debug("Caching b/c date exists and max-age > 0")
  367. expires_time = max_age
  368. self._cache_set(
  369. cache_url,
  370. request,
  371. response,
  372. body,
  373. expires_time,
  374. )
  375. # If the request can expire, it means we should cache it
  376. # in the meantime.
  377. elif "expires" in response_headers:
  378. if response_headers["expires"]:
  379. expires = parsedate_tz(response_headers["expires"])
  380. if expires is not None:
  381. expires_time = calendar.timegm(expires[:6]) - date
  382. else:
  383. expires_time = None
  384. logger.debug(
  385. "Caching b/c of expires header. expires in {} seconds".format(
  386. expires_time
  387. )
  388. )
  389. self._cache_set(
  390. cache_url,
  391. request,
  392. response,
  393. body,
  394. expires_time,
  395. )
  396. def update_cached_response(
  397. self, request: PreparedRequest, response: HTTPResponse
  398. ) -> HTTPResponse:
  399. """On a 304 we will get a new set of headers that we want to
  400. update our cached value with, assuming we have one.
  401. This should only ever be called when we've sent an ETag and
  402. gotten a 304 as the response.
  403. """
  404. assert request.url is not None
  405. cache_url = self.cache_url(request.url)
  406. cached_response = self._load_from_cache(request)
  407. if not cached_response:
  408. # we didn't have a cached response
  409. return response
  410. # Lets update our headers with the headers from the new request:
  411. # http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-26#section-4.1
  412. #
  413. # The server isn't supposed to send headers that would make
  414. # the cached body invalid. But... just in case, we'll be sure
  415. # to strip out ones we know that might be problematic due to
  416. # typical assumptions.
  417. excluded_headers = ["content-length"]
  418. cached_response.headers.update(
  419. {
  420. k: v
  421. for k, v in response.headers.items()
  422. if k.lower() not in excluded_headers
  423. }
  424. )
  425. # we want a 200 b/c we have content via the cache
  426. cached_response.status = 200
  427. # update our cache
  428. self._cache_set(cache_url, request, cached_response)
  429. return cached_response