Client-independent HTTP caching rules for Python: RFC 9111 and the RFC 5861 extensions, with no runtime dependencies.
from http_cache_rules import Action, CachePolicy, Headers, Request, Response
request = Request("GET", "https://example.test/data", Headers({"Accept-Language": "en"}))
response = Response(200, Headers([("Cache-Control", "max-age=60"), ("ETag", '"v1"')]))
policy = CachePolicy(request, response, shared=True)
if policy.storable():
action = policy.evaluate(request) # Action.SERVE while fresh
headers = policy.response_headers() # stored headers plus a computed Age
conditional = policy.revalidation_headers() # If-None-Match / If-Modified-SincePython HTTP caches embed their policy in a client: CacheControl wraps requests, requests-cache wraps requests, hishel wraps httpx. Code that needs the decisions without the client (a proxy, an async framework, a custom fetcher) has to reimplement them. hishel's SyncCacheProxy is client-independent, but it brings its storage layer, and its decisions are the ones measured below.
Measured: the http-tests/cache-tests suite at commit d644cf4, each library wrapped in a stdlib reverse proxy (bench/suite.sh for this package). "Decided" means passed plus failed; tests that depend on a failed test, or that need a browser, are not counted.
| Library | Required pass / fail | % of decided required | Optional pass / fail |
|---|---|---|---|
| http-cache-rules (shared) | 147 / 0 | 100.0 | 84 / 11 |
hishel 1.4.0 (SyncCacheProxy, no httpx) |
108 / 18 | 85.7 | 65 / 23 |
| hishel 1.4.0 (httpx client) | 106 / 18 | 85.5 | 65 / 23 |
| CacheControl 0.14.4 | 77 / 21 | 78.6 | 33 / 49 |
| requests-cache 1.3.3 | 81 / 29 | 73.6 | 44 / 38 |
The suite excludes its cdn-cache-control and interim groups from these counts. The incumbents ran in their default configuration through the same harness style (requests-cache with expire_after=0, otherwise it caches everything forever). Their counts move by one or two tests between runs. The suite's own README notes that passing it proves little, and it says nothing about behavior it does not test.
The failures common to the incumbents that this package handles: invalid Expires accepted as valid, no computed Age on reuse, Vary values like *, * or , * treated as a normal field list, hop-by-hop and Connection-listed headers stored, and invalidation by PUT/DELETE/unknown methods.
pip install http-cache-rules
Python 3.11 or newer. No dependencies.
evaluate(request, now) returns an Action: SERVE, SERVE_STALE, SERVE_STALE_THEN_REVALIDATE, REVALIDATE or MISS. It applies request Cache-Control (no-cache, max-age, min-fresh, max-stale), Pragma: no-cache, response no-cache, must-revalidate, proxy-revalidate, s-maxage and stale-while-revalidate.
Add policy.revalidation_headers() to the forwarded request. On a 304, policy.update(response) returns the merged policy. On any other response, build a new CachePolicy.
policy.can_serve_stale(now, error=True) answers whether a stale response may cover an origin failure, honoring stale-if-error and the prohibitions above.
invalidated_urls(request, response) lists the URLs to drop after a successful unsafe request. policy.invalidates(request, response) checks one stored policy.
policy.to_dict() returns JSON-safe data and CachePolicy.from_dict() restores it. The policy holds headers only. Bodies belong to the caller.
examples/adapters.py builds policies from httpx and requests response objects without importing either library. It is an example, not part of the package.
CachePolicy(request, response, *, shared=True, request_time=None, response_time=None, heuristic_fraction=0.1, heuristic_max=None, cacheable_methods=("GET", "HEAD"))
| Option | Type | Default | Description |
|---|---|---|---|
shared |
bool |
True |
Shared cache (honors s-maxage, private, proxy-revalidate, Authorization rules) or private cache. |
request_time |
float | None |
response_time |
POSIX time the request was sent, for response-delay correction. |
response_time |
float | None |
now | POSIX time the response arrived. |
heuristic_fraction |
float |
0.1 |
Fraction of Date - Last-Modified used as heuristic freshness. 0 disables. |
heuristic_max |
float | None |
None |
Upper bound for heuristic freshness in seconds. |
cacheable_methods |
iterable of str |
GET, HEAD |
Methods whose responses may be stored. |
| Method | Returns | Description |
|---|---|---|
storable() |
bool |
RFC 9111 section 3 storage rules. |
satisfies(request) |
bool |
URL, method and every Vary rule. A * anywhere in Vary never matches. |
freshness_lifetime() |
float |
s-maxage (shared), max-age, Expires - Date, then heuristic. Invalid information gives 0. |
age(now) |
float |
Current age with Age, Date and response delay. |
is_fresh(now) |
bool |
Age below lifetime. Ignores no-cache and request directives. |
can_serve_stale(now, error=False, request=None) |
bool |
Stale reuse allowed by stale-while-revalidate, request max-stale, or (with error) stale-if-error. |
evaluate(request, now) |
Action |
Combines the above for one request. |
response_headers(now, revalidated=False) |
Headers |
Stored headers with a computed Age. Date is not rewritten. Qualified no-cache and (shared) private fields are removed. |
revalidation_headers() |
Headers |
If-None-Match and If-Modified-Since. |
validates(response) |
bool |
Whether the response's validators match the stored ones. |
update(response, *, request_time, response_time) |
CachePolicy |
Section 3.2 merge. Raises ValueError when validates is false. |
invalidates(unsafe_request, response) |
bool |
Section 4.4 for this policy's URL. |
conditional_matches(request) |
bool |
Whether a client's If-None-Match or If-Modified-Since allows a 304. |
to_dict() / from_dict() |
Persistence. |
Other exports: Request, Response (dataclasses with Headers), Headers (case-insensitive, repeated lines kept separate), CacheControl (strict parser), parse_http_date, format_http_date, invalidated_urls.
Parsing rules: directive names are case-insensitive; quoted values are accepted; a member with a space around = is ignored; max-age must be 1*DIGIT (larger than 2^31 is capped) and anything else, or two disagreeing values, counts as stale. Dates must be IMF-fixdate, RFC 850 or asctime with GMT; anything else is invalid, and an invalid Expires means expired. Age uses the first list member of the first line and is ignored when not 1*DIGIT.
The policy stores the response headers (minus hop-by-hop and Connection-listed fields), the status, the timestamps, and only the request headers that matter later: those named by Vary, a marker for Authorization and no-store. Every decision is computed from those on demand, from an explicit now, so the object is deterministic and testable.
- Headers only. No body, no storage, no key management, no I/O.
206and range handling are not implemented: partial responses are not storable and there is no range combining.POSTresponses are storable only by listingPOSTincacheable_methods; theContent-Locationreuse rule is not implemented.- The weekday in an HTTP-date is checked for shape, not for agreement with the date.
Varynormalization covers whitespace, list combining, and case and order forAccept-LanguageandAccept-Encoding. It does not select by q-value.If-Modified-Sincewith noLast-Modifiedcompares againstDate.immutable(RFC 8246) is not interpreted.Warningheaders are not generated.- The suite's
cdn-cache-controlandinterimgroups are not scored.
Suite results that are not passes are listed in the Compatibility section.
Run on Python 3.14.7 (macOS) only: pytest, mypy --strict, and the suite. Python 3.11 to 3.13 are configured in CI and not run here. The suite needs Node and git; it was run with the Node on this machine.
Suite (bench/suite.sh, shared mode, three runs): required 147 passed, 0 failed, 2 not decided (dependency on a failed partial test), 3 not decided (browser only); optional 84 passed, 11 failed. All three runs gave identical counts. Not passing optional tests, shared mode:
method-POST: needs theContent-Locationrule above.vary-normalise-lang-select: selects a stored variant byqvalue; not implemented.conditional-lm-fresh-no-lm: expects 304 for anIf-Modified-Sinceolder than the response'sDatewhen there is noLast-Modified; the rules in RFC 9110 give 200, so this package answers 200.partial-*(7 tests): range support is not implemented.
Not decided: cc-resp-immutable-* (browser only), and the required partial-use-headers and partial-use-stored-headers (they depend on the optional partial tests, which fail here).
In private mode (bench/suite.sh --private) the private-only tests pass and the shared-only tests (freshness-s-maxage-shared, cc-resp-private-shared, stale-close-proxy-revalidate, stale-close-s-maxage=2, other-authorization) fail by design: 139 required passed and those 5 failed, 5 not decided; optional 79 passed and 11 failed.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest -q
.venv/bin/python -m mypy --strict
bench/suite.sh # fetches cache-tests (BSD-3-Clause) into .cache-tests/ at a pinned commit
bench/suite.sh --private
bench/suite.sh clones the suite into the gitignored .cache-tests/, checks out commit d644cf4bf487763646aac19d2c8b846daa0f604d, verifies HEAD, and runs npm install there (the suite ships no lockfile). Nothing from the suite is committed. bench/proxy.py is the reverse-proxy cache; all caching decisions in it come from this package.
MIT. See LICENSE.