1
0
mirror of https://github.com/apache/httpd.git synced 2026-10-23 22:49:01 +03:00
Files
apache/test/modules/proxy/test_07_cache_control.py
Arturo Bernal 6da0450e00 test: Fix a flaky test_proxy_07_cache_control.py (seen on Windows CI).
The cache entry for the first request is committed by the CACHE_SAVE
filter on the EOS-only pass that follows the body, which can still be
running server side for an instant after the client has already read
the complete body. Two back-to-back requests with no delay can race
this, making the second one miss the cache and hit the backend again.

Give the commit room to land before relying on it, instead of
asserting immediately after the first request returns. Reproduced
locally (about 1 in 5 runs failing, on different parametrized cases
each time, none of them involving a comma) and confirmed fixed (0
failures in 55 runs) before and after this change.

Also harden the TCPFaker backend used by this test to close its
socket with an orderly shutdown() instead of an abrupt close(), which
removes one more way for a slow platform to see a reset instead of a
clean end of response.

GitHub: closes #864


git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1939305 13f79535-47bb-0310-9956-ffa450edef68
2026-10-21 05:12:57 +00:00

174 lines
6.7 KiB
Python

import os
import time
import uuid
import pytest
from pyhttpd.conf import HttpdConf
from .env import TCPFaker
# Backend answering /<run>/<index> with the Cache-Control header lines of
# CASES[index], counting the requests it gets for every path.
class _CacheFaker(TCPFaker):
def __init__(self, host, port, cases):
super().__init__(host, port)
self.cases = cases
self.hits = {}
def _make_response(self, data):
head = data.split(b"\r\n\r\n")[0].decode("latin-1").split("\r\n")
path = head[0].split(" ")[1]
self.hits[path] = self.hits.get(path, 0) + 1
cookie = ""
for line in head[1:]:
if line.lower().startswith("cookie:"):
cookie = line.split(":", 1)[1].strip()
cc, extra = self.cases[int(path.rsplit("/", 1)[1])]
body = f"hit={self.hits[path]} cookie={cookie}\n"
lines = ["HTTP/1.1 200 OK",
"Last-Modified: Thu, 01 Jan 2026 00:00:00 GMT",
'ETag: "t"',
"Content-Type: text/plain",
f"Content-Length: {len(body)}",
"Connection: close"]
lines += [f"Cache-Control: {v}" for v in cc]
lines += extra
return ("\r\n".join(lines) + "\r\n\r\n" + body).encode()
# Cache-Control field lines and further response headers of the backend
NO_STORE = [
[["no-store"], []],
[["private"], []],
[["private, no-store"], []],
[["private,no-store,max-age=0"], []],
[["private, no-store, max-age=0"], []],
[["private , no-store , max-age=0"], []],
[["max-age=60, private"], []],
[["max-age=60, no-store"], []],
[["no-store, max-age=60"], []],
[["no-store, foo=bar"], []],
[["foo=bar, no-store"], []],
[["foo=\"a, b\", no-store"], []],
[["private", "no-store", "max-age=0"], []],
]
STORED = [
[["public, max-age=60"], []],
[["max-age=60"], []],
[["max-age=60, must-revalidate"], []],
[["must-revalidate, max-age=60"], []],
[["public, s-maxage=60"], []],
[["max-age=60, stale-while-revalidate=30"], []],
[["max-age=60, stale-if-error=30"], []],
[["foo=\"a, b\", max-age=60"], []],
]
QUALIFIED = [
[["max-age=60, private=\"X-Secret\""], []],
[["max-age=60, private=\"X-Secret, Set-Cookie\""], []],
[["max-age=60, no-cache=\"X-Secret,Set-Cookie\""], []],
]
USER = [
[["private, no-store, max-age=0"], []],
]
CASES = NO_STORE + STORED + QUALIFIED + USER
for _c in QUALIFIED:
_c[1] += ["Set-Cookie: a=b", "X-Secret: s", "X-Public: p"]
class TestCacheControl:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env):
faker = _CacheFaker("127.0.0.1", env.http_port2, CASES)
faker.start()
cache_dir = os.path.join(env.gen_dir, "cache-control")
os.makedirs(cache_dir, exist_ok=True)
conf = HttpdConf(env)
conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port,
doc_root="htdocs", with_ssl=False)
conf.add([
f'CacheRoot "{cache_dir}"',
'CacheEnable disk "/cc/"',
'CacheHeader on',
'CacheStorePrivate Off',
'CacheStoreNoStore Off',
f"ProxyPass /cc/ http://127.0.0.1:{env.http_port2}/",
])
conf.end_vhost()
conf.install()
assert env.apache_restart() == 0
TestCacheControl.faker = faker
TestCacheControl.run = uuid.uuid4().hex[:12]
yield
faker.stop()
def get(self, env, index, cookie=None):
url = env.mkurl("http", "test1", f"/cc/{self.run}/{index}")
options = ["-H", f"Cookie: {cookie}"] if cookie else []
r = env.curl_get(url, options=options)
assert r.response["status"] == 200, f"{r}"
return r.response
def backend_hits(self, index):
return self.faker.hits.get(f"/{self.run}/{index}", 0)
# a response that must not be stored is never served from the cache
@pytest.mark.parametrize("index", range(len(NO_STORE)))
def test_proxy_07_001(self, env, index):
cc = ", ".join(CASES[index][0])
r1 = self.get(env, index)
r2 = self.get(env, index)
assert self.backend_hits(index) == 2, f"Cache-Control: {cc} was cached"
assert not r1["header"].get("x-cache", "").startswith("HIT")
assert not r2["header"].get("x-cache", "").startswith("HIT")
assert r2["body"] == b"hit=2 cookie=\n"
# a response that may be stored is served from the cache
@pytest.mark.parametrize("index", range(len(NO_STORE),
len(NO_STORE) + len(STORED)))
def test_proxy_07_002(self, env, index):
cc = ", ".join(CASES[index][0])
r1 = self.get(env, index)
# The cache entry for r1 is committed by the CACHE_SAVE filter on
# the EOS-only pass that follows the body, which can still be
# running server side for an instant after the body itself has
# already reached this process. Give it room to land before relying
# on it, instead of risking a spurious backend hit on r2.
time.sleep(0.1)
r2 = self.get(env, index)
assert self.backend_hits(index) == 1, f"Cache-Control: {cc} not cached"
assert r2["header"]["x-cache"].startswith("HIT")
assert r2["body"] == r1["body"]
# fields listed in private="..." and no-cache="..." are not stored,
# whether one or several are listed, the rest of the response is
@pytest.mark.parametrize("index", range(len(NO_STORE) + len(STORED),
len(NO_STORE) + len(STORED) + len(QUALIFIED)))
def test_proxy_07_003(self, env, index):
cc = ", ".join(CASES[index][0])
r1 = self.get(env, index)
assert r1["header"]["x-secret"] == "s"
assert r1["header"]["set-cookie"] == "a=b"
# See test_proxy_07_002: give the CACHE_SAVE filter's commit time
# to land before asserting on it.
time.sleep(0.1)
r2 = self.get(env, index)
assert self.backend_hits(index) == 1, f"Cache-Control: {cc} not cached"
assert r2["header"]["x-cache"].startswith("HIT")
assert r2["header"]["x-public"] == "p"
assert "x-secret" not in r2["header"], f"X-Secret stored: {cc}"
if "Set-Cookie" in cc:
assert "set-cookie" not in r2["header"], f"Set-Cookie stored: {cc}"
# a user specific response is not given to another request
def test_proxy_07_004(self, env):
index = len(CASES) - 1
r1 = self.get(env, index, cookie="session=first")
assert r1["body"] == b"hit=1 cookie=session=first\n"
r2 = self.get(env, index)
assert b"session=first" not in r2["body"]
assert self.backend_hits(index) == 2