Allow the caching of the /versions and /auth_metadata endpoints (#19530)

Can be reviewed commit by commit.

This sets caching headers on the /versions and /auth_metadata endpoints
to:

- allow clients to cache the response for up to 10 minutes
(`max-age=600`)
- allow proxies to cache the response for up to an hour
(`s-maxage=3600`)
- make proxies serve stale response for up to an hour (`s-maxage=3600`)
but make them refresh their response after 10 minutes
(`stale-while-revalidate=600`) so that we always have a snappy response
to client, but also have fresh responses most of the time
- only cache the response for unauthenticated requests on /versions
(`Vary: Authorization`)

I'm not too worried about the 1h TTL on the proxy side, as with the
`stale-while-revalidate` directive, one just needs to do two requests
after 10 minutes to get a fresh response from the cache.

The reason we want this, is that clients usually load this right away,
leading to a lot of traffic from people just loading the Element Web
login screen with the default config. This is currently routed to
`client_readers` on matrix.org (and ESS) which can be overwhelmed for
other reasons, leading to slow response times on those endpoints (3s+).

Overwhelmed workers shouldn't prevent people from logging in, and
shouldn't result in a long loading spinner in clients. This PR allows
caching proxies (like Cloudflare) to publicly cache the unauthenticated
response of those two endpoints and make it load quicker, reducing
server load as well.
This commit is contained in:
Quentin Gliech
2026-03-12 18:11:09 +01:00
committed by GitHub
parent 3ce5508c7e
commit 4c475dcd7a
4 changed files with 83 additions and 2 deletions

1
changelog.d/19530.misc Normal file
View File

@@ -0,0 +1 @@
Allow caching of the `/versions` and `/auth_metadata` public endpoints.

View File

@@ -861,7 +861,18 @@ def respond_with_json(
encoder = _encode_json_bytes
request.setHeader(b"Content-Type", b"application/json")
request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
# Insert a default Cache-Control header if the servlet hasn't already set one. The
# default directive tells both the client and any intermediary cache to not cache
# the response, which is a sensible default to have on most API endpoints.
# The absence `Cache-Control` header would mean that it's up to the clients and
# caching proxies mood to cache things if they want. This can be dangerous, which is
# why we explicitly set a "don't cache by default" policy.
# In practice, `no-store` should be enough, but having all three directives is more
# conservative in case we encounter weird, non-spec compliant caches.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives
# for more details.
if not request.responseHeaders.hasHeader(b"Cache-Control"):
request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
if send_cors:
set_cors_headers(request)
@@ -901,7 +912,18 @@ def respond_with_json_bytes(
request.setHeader(b"Content-Type", b"application/json")
request.setHeader(b"Content-Length", b"%d" % (len(json_bytes),))
request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
# Insert a default Cache-Control header if the servlet hasn't already set one. The
# default directive tells both the client and any intermediary cache to not cache
# the response, which is a sensible default to have on most API endpoints.
# The absence `Cache-Control` header would mean that it's up to the clients and
# caching proxies mood to cache things if they want. This can be dangerous, which is
# why we explicitly set a "don't cache by default" policy.
# In practice, `no-store` should be enough, but having all three directives is more
# conservative in case we encounter weird, non-spec compliant caches.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives
# for more details.
if not request.responseHeaders.hasHeader(b"Cache-Control"):
request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
if send_cors:
set_cors_headers(request)

View File

@@ -49,6 +49,25 @@ class AuthIssuerServlet(RestServlet):
self._auth = hs.get_auth()
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# This endpoint is unauthenticated and the response only depends on
# the metadata we get from Matrix Authentication Service. Internally,
# MasDelegatedAuth/MSC3861DelegatedAuth.issuer() are already caching the
# response in memory anyway. Ideally we would follow any Cache-Control directive
# given by MAS, but this is fine for now.
#
# - `public` means it can be cached both in the browser and in caching proxies
# - `max-age` controls how long we cache on the browser side. 10m is sane enough
# - `s-maxage` controls how long we cache on the proxy side. Since caching
# proxies usually have a way to purge caches, it is fine to cache there for
# longer (1h), and issue cache invalidations in case we need it
# - `stale-while-revalidate` allows caching proxies to serve stale content while
# revalidating in the background. This is useful for making this request always
# 'snappy' to end users whilst still keeping it fresh
request.setHeader(
b"Cache-Control",
b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
)
if self._config.mas.enabled:
assert isinstance(self._auth, MasDelegatedAuth)
return 200, {"issuer": await self._auth.issuer()}
@@ -94,6 +113,25 @@ class AuthMetadataServlet(RestServlet):
self._auth = hs.get_auth()
async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
# This endpoint is unauthenticated and the response only depends on
# the metadata we get from Matrix Authentication Service. Internally,
# MasDelegatedAuth/MSC3861DelegatedAuth.issuer() are already caching the
# response in memory anyway. Ideally we would follow any Cache-Control directive
# given by MAS, but this is fine for now.
#
# - `public` means it can be cached both in the browser and in caching proxies
# - `max-age` controls how long we cache on the browser side. 10m is sane enough
# - `s-maxage` controls how long we cache on the proxy side. Since caching
# proxies usually have a way to purge caches, it is fine to cache there for
# longer (1h), and issue cache invalidations in case we need it
# - `stale-while-revalidate` allows caching proxies to serve stale content while
# revalidating in the background. This is useful for making this request always
# 'snappy' to end users whilst still keeping it fresh
request.setHeader(
b"Cache-Control",
b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
)
if self._config.mas.enabled:
assert isinstance(self._auth, MasDelegatedAuth)
return 200, await self._auth.auth_metadata()

View File

@@ -81,6 +81,26 @@ class VersionsRestServlet(RestServlet):
msc3575_enabled = await self.store.is_feature_enabled(
user_id, ExperimentalFeature.MSC3575
)
else:
# Allow caching of unauthenticated responses, as they only depend
# on server configuration which rarely changes.
#
# - `public` means it can be cached both in the browser and in caching proxies
# - `max-age` controls how long we cache on the browser side. 10m is sane enough
# - `s-maxage` controls how long we cache on the proxy side. Since caching
# proxies usually have a way to purge caches, it is fine to cache there for
# longer (1h), and issue cache invalidations in case we need it
# - `stale-while-revalidate` allows caching proxies to serve stale content while
# revalidating in the background. This is useful for making this request always
# 'snappy' to end users whilst still keeping it fresh
request.setHeader(
b"Cache-Control",
b"public, max-age=600, s-maxage=3600, stale-while-revalidate=600",
)
# Tell caches to vary on the Authorization header, so that
# authenticated responses are not served from cache.
request.setHeader(b"Vary", b"Authorization")
return (
200,