Source code for action0.github.conditional

"""
Conditional requests (:py:class:`ConditionalRequestsHook`): GitHub's
recommended way to save rate limit.

GitHub answers most GETs with an ``ETag`` (and ``Last-Modified``). Repeat
the request with ``If-None-Match`` (/ ``If-Modified-Since``) and, if
nothing changed, GitHub replies ``304 Not Modified`` — with an empty body,
and **without counting against the primary rate limit**. The hook keeps a
store of ETagged responses, attaches the validators on the way out and
fills a 304 from the store on the way in.

Because it is a :py:class:`~action0.client.hooks.Hook` — not a backend
wrapper — a *single* instance drives every execution model: hooks run
inside all backend base classes, sync, async and Twisted alike::

    hook = ConditionalRequestsHook()
    backend = RequestsBackend(hooks=[hook])  # or AsyncHttpxBackend(hooks=[hook]), ...
    client = GitHubClient(backend, token="ghp_...")

Layer :py:class:`~action0.client.caching.CachingSyncBackend` (or its
async/Deferred siblings) around such a backend and hot data is served
without any request for the cache's TTL — after which the request that
does go out is a revalidation, and usually free.
"""

import math

from action0.client import CachePolicy
from action0.client import CacheStore
from action0.client import Hook
from action0.client import MemoryCache
from action0.req import Request
from action0.req import Response

_STASH_KEY = "action0-github:conditional-stored"
"""The ``request.meta`` key carrying the stored response between
:py:meth:`ConditionalRequestsHook.on_request` and
:py:meth:`ConditionalRequestsHook.on_response` — per-request state, so a
shared hook instance stays free of races."""

GITHUB_CONDITIONAL_POLICY = CachePolicy(ttl=math.inf, vary_headers=("Accept", "Authorization"))
"""The default storage policy: entries never expire (an ETag stays valid
until the resource changes; the store's own eviction bounds memory) and
the key varies on ``Accept`` and ``Authorization`` — a different token or
media type is a different cache entry. Keys are sha256 digests, so the
token value never appears in a key."""


[docs] class ConditionalRequestsHook(Hook): """ The conditional-requests hook: stores ETagged responses, revalidates with ``If-None-Match`` / ``If-Modified-Since``, and turns GitHub's ``304 Not Modified`` back into the stored full response — transparently to the operations, which only ever see the 200. The whole revalidation cycle, against a stub backend: >>> from action0.client.testing import StubBackend >>> from action0.req import Request, Response >>> >>> backend = StubBackend( ... Response(200, body='{"name": "cpython"}', headers={"ETag": '"abc"'}), ... Response(304), ... hooks=[ConditionalRequestsHook()], ... ) >>> request = Request("https://api.github.com/repos/python/cpython") >>> backend.send(request).body_str() # stored under its ETag '{"name": "cpython"}' >>> backend.send(request.copy()).body_str() # the 304 is filled from the store '{"name": "cpython"}' >>> backend.requests[1].headers["If-None-Match"] '"abc"' """ def __init__( self, store: CacheStore | None = None, policy: CachePolicy = GITHUB_CONDITIONAL_POLICY, ) -> None: """ :param store: where the ETagged responses live — any (synchronous) :py:class:`~action0.client.caching.CacheStore`; the default is a fresh, thread-safe :py:class:`~action0.client.caching.MemoryCache`. Hooks run synchronously even on async backends, so an ``AsyncCacheStore`` is not accepted. :param policy: what to store under which key — :py:data:`GITHUB_CONDITIONAL_POLICY` unless told otherwise """ self.store = store if store is not None else MemoryCache() self.policy = policy
[docs] def on_request(self, request: Request) -> Request | None: """ Attach the stored validators to an outgoing GET/HEAD: the stored response's ``ETag`` as ``If-None-Match`` (and ``Last-Modified`` as ``If-Modified-Since``). A request already carrying its own validators is the caller's conditional request — left untouched. :param request: the request about to be sent (mutated in place) :return: ``None`` — the given request is the one sent """ if not self.policy.should_lookup(request): return None if "If-None-Match" in request.headers or "If-Modified-Since" in request.headers: return None stored = self.store.get(self.policy.key_for(request)) if stored is None: return None etag = stored.headers.get("ETag") last_modified = stored.headers.get("Last-Modified") if etag is None and last_modified is None: return None if etag is not None: request.headers.set("If-None-Match", etag) if last_modified is not None: request.headers.set("If-Modified-Since", last_modified) # carry the matched entry to on_response on the request itself: # immune to races on a shared hook and to eviction in between request.meta[_STASH_KEY] = stored return None
[docs] def on_response(self, request: Request, response: Response, elapsed: float) -> Response | None: """ Fill a ``304 Not Modified`` from the store, and store fresh responses that carry validators. :param request: the request that was sent :param response: the response that arrived :param elapsed: the seconds the exchange took (unused) :return: the stored full response for a revalidated 304 (an independent copy tied to the current request), else ``None`` to keep the given response """ stored = request.meta.pop(_STASH_KEY, None) if response.status == 304 and isinstance(stored, Response): # refresh the entry so LRU/TTL recency follows actual use self.store.set(self.policy.key_for(request), stored, self.policy.ttl) return stored.copy(request=request) if self.policy.should_store(request, response) and ( response.headers.get("ETag") is not None or response.headers.get("Last-Modified") is not None ): self.store.set(self.policy.key_for(request), response.copy(), self.policy.ttl) return None