Always send Access-Control-Allow-Origin so CDN-cached responses stay usable - #1917
Conversation
…usable
The web client intermittently showed no data - an empty conference list, an
empty schedule - with no error, then recovered on its own.
Apollo sends persisted queries as GETs and those responses are
public, max-age=1800, so Cloud CDN caches them. The cache key is
{protocol, host, query string, conference header} and does not include Origin.
CorsWebFilter follows the CORS spec and only emits
Access-Control-Allow-Origin when the request has an Origin header, which
browsers send and the mobile apps do not. So whichever client warmed a cache
entry decided for the next 30 minutes whether browsers could read it: an entry
warmed by a mobile request carried no CORS header, and every browser served
that copy had the response blocked. The mobile apps poll far more than anyone
opens the web client, so they usually won that race.
The policy is "*" for every caller anyway, so emit the header unconditionally
and every cached copy is valid for everyone. The filter only fills in a header
CorsWebFilter did not already set, leaving real Origin requests untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Syr6Ss5YAKH69qjbVUe4
|
@martinbonnin again this is Claude's findings so not certain there isn't better way. These were options it proposed `Option 1 — always emit the header at the origin (what I implemented in #1917) Make the backend send Access-Control-Allow-Origin: * even when the request has no Origin header. Since the policy is * for everyone anyway, whichever client warms a cache entry now produces a copy that's valid for all callers.
Option 2 — add Origin to the CDN cache key Add "Origin" to include_http_headers in the graphql backend service's cache_key_policy in backend/terraform/main.tf (currently ["conference"]), so browser and non-browser responses cache separately.
I recommended Option 1 because Option 2 papers over the response being wrong rather than correcting it, and it makes caching less effective. That said, they aren't mutually exclusive — if you wanted belt-and-braces, Option 2 on top would isolate browser traffic even if the origin behaviour regressed later. |
|
a bit more background (I asked Claude why this had started happening).....again if correct! Seemed to be triggered by deployment yesterday: " What that deploy carried. The backend's own source is byte-identical between 512cf2c and 8bf79ee — the only diffs are backend/terraform/main.tf and the version catalog. The relevant catalog change is Apollo 5.0.1 → 5.1.0 (#1868, also merged yesterday). Why that flips the bug on — two things reset the CDN key space simultaneously:
Result: every entry got re-warmed from scratch yesterday, by whichever client asked first. The Android and iOS apps poll constantly and never send Origin, so they won most of those races — writing header-less copies that browsers then can't read. Before yesterday, the key space had been stable for weeks, and entries that happened to be warmed correctly kept getting refreshed in a working state. Confidence: the mechanism is measured and certain. The trigger — redeploy plus key-space reset — is strongly supported by the timeline and the observed URL difference, but I can't prove which client warmed which entry historically, since Cloud CDN doesn't expose that. Two implications worth noting:
|
|
Wow, thanks for opening that. TIL 👍 |

Summary
Fixes the web client intermittently showing no data — empty conference list, empty schedule — with no error in the UI, recovering on its own after a while.
Cause. Apollo sends persisted queries as
GETs, and those responses arepublic, max-age=1800, so Cloud CDN caches them. The cache key is{protocol, host, query string, conference header}and does not includeOrigin(backend/terraform/main.tf, thegraphqlbackend service'scache_key_policy).CorsWebFiltercorrectly follows the CORS spec and only emitsAccess-Control-Allow-Originwhen the request has anOrigin— which browsers send and the mobile apps do not.So whichever client warms a cache entry decides, for the next 30 minutes, whether browsers can read it:
Origin)Origin)The mobile apps poll far more often than anyone opens the web client, so they usually win that race — which is why the web client is the visible victim of traffic it doesn't generate, and why it comes and goes on a ~30 minute cycle.
Measured on production before the fix:
From the page context the request fails with
TypeError: Failed to fetch— a CORS block, not an HTTP error the app can surface, which is why it renders as a silent empty list rather than an error view.I also confirmed the entry is sticky regardless of caller: after warming
GetConferenceDatawith anOriginrequest, the cached copy returnedaccess-control-allow-origin: *even to a request sending noOrigin. Nothing distinguishes the conference-list query from the per-conference one; both are equally exposed.Fix. The CORS policy is
*for every caller anyway, so emit the header unconditionally. Every cached copy is then valid for every client, with no cache fragmentation. The filter only fills in a headerCorsWebFilterdid not already set, so genuineOriginrequests keep their spec-compliant handling.Adding
Originto the CDN cache key would also work, but fragments the cache per origin and leaves the no-Originvariant still missing the header.Test plan
:backend:service-graphql:compileKotlin— successBackend Test(:backend:service-graphql:build)Not verified locally
I could not run the service on this machine —
bootRunfails withIllegalStateException: no credentials found for firebase_service_account_key.json, and I did not work around that. So the header behaviour is verified by reading the filter, not by executing it.backend/service-graphqlcurrently has no test sources, and onlytestImplementation(libs.junit)— nospring-test/WebTestClient. A regression test for this is worth having (the bug is subtle and invisible in the UI), but it means adding test dependencies and a test source set to a module that has neither, so I left that as a separate call rather than bundling it in.🤖 Generated with Claude Code
https://claude.ai/code/session_01E3Syr6Ss5YAKH69qjbVUe4