From 4c475dcd7a566367708c59642d17cf7fdd3c0507 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 12 Mar 2026 18:11:09 +0100 Subject: [PATCH] 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. --- changelog.d/19530.misc | 1 + synapse/http/server.py | 26 +++++++++++++++++-- synapse/rest/client/auth_metadata.py | 38 ++++++++++++++++++++++++++++ synapse/rest/client/versions.py | 20 +++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 changelog.d/19530.misc diff --git a/changelog.d/19530.misc b/changelog.d/19530.misc new file mode 100644 index 0000000000..9e5bc0fe04 --- /dev/null +++ b/changelog.d/19530.misc @@ -0,0 +1 @@ +Allow caching of the `/versions` and `/auth_metadata` public endpoints. diff --git a/synapse/http/server.py b/synapse/http/server.py index 226cb00831..2c235e04f4 100644 --- a/synapse/http/server.py +++ b/synapse/http/server.py @@ -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) diff --git a/synapse/rest/client/auth_metadata.py b/synapse/rest/client/auth_metadata.py index 702f550906..062b8ed13e 100644 --- a/synapse/rest/client/auth_metadata.py +++ b/synapse/rest/client/auth_metadata.py @@ -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() diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py index 23f5ffeedb..f8d7a1a4d9 100644 --- a/synapse/rest/client/versions.py +++ b/synapse/rest/client/versions.py @@ -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,