Trois PR, une cause racine : le corps HTML varie selon l'état de connexion, le UA et la flash — propriétés que la cache key du CDN ignore — et le contenu fuit d'un utilisateur à l'autre. Faire lire la chaîne de PR à Claude fait émerger trois patterns réutilisables : lazy personalize frames, masquage côté client en CSS, et garde à la frontière de cache.
Les utilisateurs arrivaient sur mon site et au bas de chaque page publique flottait une ligne "Content missing". Les premiers signalements venaient tous d'iOS — mon réflexe initial : un comportement bizarre du client Hotwire Native. C'est seulement en fouillant les logs Cloudflare que j'ai compris : ça n'a rien à voir avec le client. Le cache CDN est empoisonné, et les trois plateformes (Web / iOS / Android) peuvent tomber dans le panneau — iOS a juste été le premier à le faire remonter.
Premier réflexe : un bug Hotwire Native. Deuxième réflexe : un comportement edge bizarre de Cloudflare. Aucun des deux. Même qualifier cette catégorie de bug de "problème CDN" est faux — le CDN fait exactement ce que tu lui as dit de faire.
Quand j'ai fait lire à Claude la chaîne des PR dans l'ordre, trois PR consécutifs avaient la même cause racine : le corps HTML varie selon l'état de connexion, le UA, la flash — des propriétés du request dont la cache key du CDN ne sait absolument rien. Trois symptômes différents, une seule cause.
/, /topics, /square, /coins, /searches/app passent par le cache CDN via public_expires_in. Le layout contenait :
<% if mobile_hotwire? && user_signed_in? %>
<%= render "shared/tab_badge" %>
<% end %>
shared/tab_badge rend un turbo-frame :
<turbo-frame id="tab_badge" src="/notifications?badge_only=true"></turbo-frame>
L'intention est claire : les utilisateurs Hotwire Native connectés voient un point rouge de notification non lue dans la tab bar du bas. Le Web n'en a pas besoin (chemin de rendu distinct), les non-connectés non plus.
Le problème : la branche mobile_hotwire? && user_signed_in? fait que la même URL produit deux HTML différents. Mais la cache key du CDN ne regarde que des choses comme l'URL et l'en-tête Accept — elle n'a aucune idée si tu es connecté ou non.
Chronologie :
/. Le CDN va à l'origine et cache le HTML contenant le tab-badge frame./. Le CDN tape le même cache et lui sert ce HTML.src, demande /notifications?badge_only=true.NotificationsController a before_action :authenticate_user!, voit qu'il n'y a pas de session et fait un 302 vers /users/sign_in.tab_badge, et il peint "Content missing".Les visiteurs non connectés des trois plateformes (Web / iOS / Android) peuvent toutes être touchés — il suffit qu'un cache slot ait été rempli en premier par un utilisateur connecté. Les signalements se sont concentrés sur iOS parce que les utilisateurs iOS ont le taux de connexion le plus élevé : la cache se contamine donc plus souvent en état connecté, et les visiteurs iOS non connectés ont la plus forte probabilité de tomber sur une entrée empoisonnée. Web et Android n'étaient pas "épargnés" : ils avaient une probabilité de déclenchement plus faible et moins de signalements — un bug qui, dans le fond, est un désastre site-wide a été déguisé par les premières données en "spécifique iOS".
Le fix se déroule en deux temps, tous deux pour empêcher la cache de se scinder selon l'état de connexion :
Étape 1 : enlever le && user_signed_in? du layout. Sinon tu joues à deviner la cache key indéfiniment :
<%# Rendre pour toutes les requêtes mobile_hotwire?, peu importe la
connexion — la branche user_signed_in? scinde le HTML d'une même
URL en deux variantes, et celle qui gagne le slot CDN est servie
au visiteur suivant. %>
<% if mobile_hotwire? %>
<%= render "shared/tab_badge" %>
<% end %>
Étape 2 : l'endpoint frame /notifications?badge_only=true doit renvoyer pour les requêtes non connectées un frame 200 structurellement identique, pas un 302. Et l'endpoint lui-même doit être private, no-store pour que le CDN ne partage pas le compteur de non-lus d'un utilisateur avec un autre.
class NotificationsController < ApplicationController
before_action :authenticate_user!, except: [:index]
def index
if params[:badge_only] || params[:count_only]
response.headers["Cache-Control"] = "private, no-store"
redirect_to notifications_path and return unless turbo_frame_request?
@unread_count = user_signed_in? ? current_user.notifications.unread.count : 0
if params[:badge_only]
render(partial: "notifications/tab_badge")
else
render(partial: "notifications/bell")
end
return
end
authenticate_user!
# ... logique normale d'index
end
end
À noter : authenticate_user! n'est contourné que pour index — et seulement pour les sous-requêtes de frame ; les requêtes non-frame passent toujours par le redirect.
Dans le même PR, un autre bug de forme identique : les utilisateurs Hotwire Native non connectés voyaient aussi le FAB de compose (bouton flottant) — même empoisonnement, une cache contaminée par l'état de connexion fuitant vers les visiteurs non connectés. La version d'origine écrivait user_signed_in? directement dans le layout pour décider si rendre le div du stimulus controller. Fix : déplacer le FAB dans un lazy frame, rendu par l'endpoint /personalize/compose_fab en private, no-store.
<%# Le layout sort toujours un frame vide %>
<% if mobile_hotwire? %>
<turbo-frame id="compose-fab" src="<%= personalize_compose_fab_path %>" loading="eager" class="hidden"></turbo-frame>
<% end %>
<%# Template /personalize/compose_fab %>
<%= turbo_frame_tag "compose-fab" do %>
<% if user_signed_in? %>
<div data-controller="bridge--compose-fab" class="hidden"></div>
<% end %>
<% end %>
Le layout sort à chaque fois un frame vide ; l'endpoint à l'intérieur du frame décide, selon l'état de connexion, s'il faut insérer le div du controller. Le HTML de la page principale ne contient aucun markup qui se ramifie par utilisateur — quoi que cache le CDN, rien ne casse.
Quelques jours après #117, un autre signalement : un utilisateur non connecté entre sur la home et voit en haut de page un bandeau flash "Connexion réussie" — perplexe.
Même maladie, nouveau symptôme.
Le layout contenait :
<%= render "shared/_notice" %>
Cette ligne rend flash[:notice] / flash[:alert]. La première requête après n'importe quel redirect porte un flash, par exemple :
redirect_to root_path après une connexion réussieredirect_to root_path après une déconnexionredirect_back(fallback_location: ...) de PunditSi la URL d'atterrissage du redirect est aussi un chemin public_expires_in, ce flash est cuit dans le HTML cacheé. Le visiteur anonyme suivant sur la même URL reçoit ce HTML — et voit la "Connexion réussie" de quelqu'un d'autre.
La tentation est de refactorer chaque view cacheable pour déplacer le rendu de la flash dans un frame. Mais la solution plus solide est d'arrêter le coup à la frontière de cache : si cette réponse porte une flash, interdiction de la cacher publiquement.
def public_expires_in(duration)
return unless Rails.env.live?
# flash est rendu directement dans le layout ; cacher cette réponse
# ferait fuiter la notice de l'utilisateur précédent au prochain
# visiteur anonyme
if flash.any?
response.headers["Cache-Control"] = "private, no-store"
else
expires_in duration, public: true
end
end
Avantages de cette approche :
public_expires_in en bénéficie automatiquement.Au passage, j'ai vu que coins#show écrivait son propre expires_in 5.minutes, public: true en contournant le helper. Unifié sur public_expires_in, sinon le même bug de fuite de flash sortirait de là.
Après le déploiement, il faut lancer une fois cloudflare:purge_personalized_pages — les entrées de cache déjà empoisonnées n'expirent pas toutes seules ; elles tiendront jusqu'à la fin du TTL de leur URL (/topics est à 1 semaine).
/topicsQuelques heures plus tard, troisième symptôme : les utilisateurs connectés sur /topics ne voyaient plus le bouton "+ Nouveau Sujet". Recharger ne servait à rien. Mais le même utilisateur, depuis une autre page, en arrivant à /topics?r=1 (paramètre aléatoire pour contourner le cache), retrouvait le bouton.
/topics est en public_expires_in 1.week — le cache le plus agressif. Le view d'origine :
<% unless mobile_hotwire? %>
<a href="<%= new_topic_path %>" class="btn-new-topic">+ Create</a>
<% end %>
<% if mobile_hotwire? %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Deux gardes : un garde UA (web vs native) et un garde auth (connecté ou non). Le CDN ne connaît ni l'un ni l'autre — celui qui frappe l'origine en premier décide la forme de la cache. Le premier visiteur web anonyme cache "pas de bouton native bridge, avec bouton web" ; le premier visiteur native connecté cache "avec bouton bridge, sans bouton web" — le destin du suivant dépend du cache slot dans lequel il atterrit.
Même approche que pour #117 :
Le bouton web — pas de branche, toujours rendu :
<a href="<%= new_topic_path %>" class="btn-new-topic web-only">+ Create</a>
.web-only le cache via CSS sous UA native. Le HTML caché par le CDN est toujours identique, le bouton toujours là, et le UA décide de l'afficher ou non — CSS est une décision côté client, le cache n'y participe pas.
Le bouton native bridge — déplacé dans un lazy personalize frame :
<% if mobile_hotwire? %>
<turbo-frame id="topic-new-button" src="<%= personalize_topic_new_button_path %>" loading="eager"></turbo-frame>
<% end %>
<%# Template /personalize/topic_new_button %>
<%= turbo_frame_tag "topic-new-button" do %>
<% if user_signed_in? %>
<button data-controller="bridge--new-topic">+</button>
<% end %>
<% end %>
Le HTML de la page principale ne contient plus que le conteneur <turbo-frame> et le bouton web — aucun des deux ne se ramifie par utilisateur. Tout markup "selon l'utilisateur, rendre ou non" a migré vers l'endpoint personalize, qui est private, no-store et n'entre jamais dans le CDN.
Chaque symptôme, sur le moment, ressemblait à un bug différent. "Content missing" ressemblait à un problème Hotwire Native ; la fuite de flash à un problème de configuration cookies/session ; le bouton manquant à une erreur de logique dans le view template.
En posant les trois PR côte à côte, la cause racine est unique : le contenu du HTML body dépend de propriétés du request hors de la cache key. Le boulot du CDN est de cacher du HTML par cache key — il ne peut absolument pas savoir que tel HTML n'est correct que pour les utilisateurs connectés, ou seulement pour Hotwire Native, ou seulement pour les requêtes portant un flash.
Trois patterns réutilisables pour éradiquer cette catégorie :
Pousser la variabilité hors du body cacheé — tout markup qui se ramifie par utilisateur devient un lazy turbo-frame, rendu par un endpoint personalize private, no-store. Le HTML de la page principale est invariant ; le CDN cache comme il veut, c'est toujours juste.
Rendre une version universelle, masquer côté client — par ex. le bouton web est toujours dans le HTML, et CSS le masque sous UA native via .web-only. La branche vit en CSS/JS, pas dans le HTML clé-de-cache.
Garde à la frontière de cache — pour les réponses dans lesquelles de l'état a déjà fuité (comme la flash), rétrograder Cache-Control à private, no-store à la frontière. Le trafic normal est intact.
Le point commun des trois : le HTML cacheé et le markup qui se ramifie par utilisateur ne doivent jamais se chevaucher.
Après le déploiement du fix, une chose de plus : les entrées CDN empoisonnées n'expirent pas toutes seules, et les TTL peuvent atteindre une semaine. Écris une rake task ponctuelle cloudflare:purge_personalized_pages qui purge activement tous les chemins suspects — sinon le bug continuera de remonter jusqu'à expiration naturelle.
Le PR #122 a été découvert dans la même période. Même forme, autre type de bug — il mérite d'être mentionné à part car il partage le même mécanisme invisible avec les bugs de cache poisoning.
La page /following utilise un lazy turbo-frame pour charger la page suivante (le pattern standard d'infinite scroll). Le src du frame est calculé par un helper appelé set_load_more_path — il décide la URL de la page suivante d'après le controller/action courant.
def set_load_more_path(page:, anchor_id: nil)
if controller_name == "coins" && action_name == "show"
path = coin_symbol_path(...)
elsif controller_name == "posts" && action_name == "hot"
path = hot_path(...)
# ... beaucoup de branches ...
elsif controller_name == "users" && action_name == "index"
path = users_path(page: page, ...)
# ...
else
path = posts_path(page: page, ...) # ← fallback
end
end
L'action posts#following n'est pas dans la liste de branches, elle tombe donc dans le fallback final posts_path — c'est-à-dire /posts, l'explore feed.
Effet : la page 1 de /following est correcte (la controller action elle-même fait @posts = following_posts), mais à partir de la page 2 le lazy frame va chercher en silence /posts?page=2, et charge le contenu de l'explore feed — des posts de gens que tu ne suis pas s'invitent dans le feed.
Le fix est court :
elsif controller_name == "posts" && action_name == "following"
path = following_feed_path(page: page, anchor_id: anchor_id, r: nil)
Ce n'est pas la même catégorie que les trois bugs de cache poisoning — la branche fausse dans set_load_more_path n'a rien à voir avec le CDN. Mais ils partagent le même mécanisme invisible : le contenu chargé par un lazy turbo-frame est du contenu que tu ne reviewes pas activement.
Le frame de #117 faisait un 302 silencieux à l'erreur ; pour #119/#121, le contenu cacheé était faux et tu ne le voyais pas ; pour #122, le chemin du frame était faux et en ne regardant que la page 1 tu ne t'en apercevais pas. Dès que tu mets du "contenu dynamique" dans un lazy frame, tu dois reviewer activement l'état après le chargement du frame — ce que Claude a attrapé pour moi en phase de PR review, c'est exactement cette checklist "que se passe-t-il après le chargement du frame".
Si ton appli Rails est derrière un CDN (Cloudflare, Fastly, n'importe lequel), il y a de fortes chances que tu sois pris dans au moins une des combinaisons suivantes :
public_expires_in / expires_in ..., public: true, y a-t-il une branche genre if user_signed_in? ?flash est-il rendu directement dans le layout ? Une route public_expires_in est-elle cachée alors qu'il y a une flash ?mobile_hotwire? qui apparaît sur des routes cacheables ?src de turbo-frame, son controller utilise-t-il before_action :authenticate_user! ? Les requêtes non connectées feront-elles un 302 ?src d'un lazy turbo-frame est-il calculé par un helper ? Ce helper a-t-il une branche fallback ? Le fallback est-il la mauvaise URL ?Demande à Claude de passer cette checklist en cinq points — si l'un d'eux matche, c'est ton prochain PR. Ces bugs ne remontent pas tout seuls : les lazy frames échouent en silence, les CDN tapent en silence, CSS masque en silence. Trois mécanismes silencieux empilés — et un bug peut se cacher en production pendant des mois.