Cache HTTP
pour les API REST
API Platform Conference
21.9.2023, Lille
© David Buchmann
C'était quoi déjà , le reverse proxy?
Qu'est-ce qui pourrait mal se passer?
- Rien n'est caché (entêtes HTTP)
- Trop est caché (aussi entêtes HTTP)
- On ne voit pas les changements récent (invalidation)
- Informations mélangés (contenu personnalisé)
httpstatusdogs.com
Aperçu
- HTTP refresher
- HTTP cache control
- Symfony et API Platform
- Invalidation du cache
- Les particularités des listes
- Conclusions
HTTP Refresher
Le noyau du HTTP
Requête
GET /path
Accept-Encoding: text/html
Réponse
HTTP/1.1 200 OK
Content-Type: text/html
<html>...</html>
HTTP verbs
- GET
- HEAD
- POST
- PUT
- DELETE
- ...
HTTP codes
- 1xx hold on
- 2xx here you go
- 3xx go away
- 4xx you fucked up
- 5xx I fucked up
twitter.com/stevelosh/status/372740571749572610
HTTP Cache Control
Cache control headers
- Cache Expiration Model
- Cache Validation Model
HTTP 1.1, RFC 2616, Sections 13.2 and 13.3
Cache Expiration
Cache-Control: s-maxage=3600, max-age=900
Expires: Thu, 15 May 2014 08:00:00 GMT
- s-maxage
- max-age
- Expires (HTTP 1.0 - avoid!)
- Default to default_ttl if nothing specified
Cache validation
- L'application met un hash sur la réponse
- Ensuite, pour chaque requête, vérifier si le hash a changé
ETag: 82901821233
If-None-Match: 82901821233
304 Not Modified
Résilience
Réponse
Cache-Control: stale-while-revalidate=3600;
Cache-Control: stale-if-error=3600;
Request
Cache-Control: must-revalidate;
Ne pas cacher
Cache-Control: s-maxage=0, private, no-cache
- s-maxage=0: Ne pas caches sur les proxies
- private: Pour un utilisateur spécifique, ne pas cache sur les proxies
- no-cache: A valider pour chaque requête (mais peut être dans la mémoire du proxy)
- no-store: Ne doit pas être placé dans la mémoire du proxy
Surrogate Control
Entête pour gérer vos substituts differament des caches inconnues
Cache-Control: no-store
Surrogate-Control: max-age=3600
Séparer les variants
Réponse depends des certains entêtes de la requête
Requête
GET /resource
Accept: application/json
GET /resource
Accept: text/xml
Réponse
Vary: Accept
API Platform default cache_headers
api_platform:
defaults:
cache_headers:
max_age: 0
shared_max_age: 3600
vary: ['Content-Type','Authorization','Origin']
Attention avec Vary: Authorization
Pour une resource
use ApiPlatform\Metadata\ApiResource;
#[ApiResource(
cacheHeaders: [
'max_age' => 60,
'shared_max_age' => 120,
'vary' => ['Accept-Language']
]
)]
class Book
{
// ...
}
Pour une seule opération
#[ApiResource]
#[Get(
cacheHeaders: [
'max_age' => 60,
'shared_max_age' => 120
]
)]
Symfony
$response->setCache([
'max-age' => 300,
]);
$response->headers
->addCacheControlDirective('must_revalidate');
$response->headers
->addCacheControlDirective('stale_if_error', 3600);
FOSHttpCacheBundle
- Cache-Control annotations/attributes
- Cache-Control configuration sur structure des URL
- User context caching: cache basé sur les permissions
Supprimer des entrés dans le cache
Invalidation du cache
There are two hard things in computer science:
- Naming things
- Cache invalidation
- Off by one errors
Cache busting
- Duré de vie trés élevé pour des assets
- Ajouter ?version aux liens vers les assets
- Le query string fait partie de la cléf du cache
<link rel="stylesheet" href="/css/style.css?v1" type="text/css"/>
...
<script src="/js/scripts.js?v1"></script>
Supprimer des entrés dans le cache
- Longue duré de vie
- On instructe le cache de supprimer une URL
- Ou de supprimer avec une regex
- Ou de supprimer tous entrés avec un tag indiqué
Varnish: Purge/Refresh, Ban, xkey
Cache Tagging avec Varnish
- xkey vmod
- xkey entêtes pour donner tous les ids du contenu de la réponse
- Instruction pour invalider tout avec un tag (xkey.purge("id44"))
$response->withHeader('xkey', 'news id42 id44');
xkey.purge(req.http.xkey-purge);
Si on fait avec BAN, c'est beaucoup moins efficace.
Intégrations
API Platform
- Response listener pour ajouter des tags
- Doctrine listener pour invalider des tags
FOSHttpCacheBundle
- Cache tagging avec annotations/attribues/service
- Invalidation tags avec annotations/attribues/service
- Intégration avec Symfony HttpCache, inclus tags
- Intégrations avec Varnish, Nginx, Fastly, Cloudflare
Comment gérer les listes?
Element  | weight |  <->  | Element  | weight |
A | 9 | | D | 22 |
B | 8 | | A | 9 |
C | 7 | | B | 8 |
D | 6 | | C | 7 |
E | 5 | | E | 5 |
F | 4 | | F | 4 |
G | 3 | | G | 3 |
H | 2 | | H | 2 |
I | 1 | | I | 1 |
Element  | weight |  <->  | Element  | weight |
A | 9 | | A | 9 |
B | 8 | | B | 8 |
C | 7 | | D | 6 |
D | 6 | | E | 5 |
E | 5 | | F | 4 |
F | 4 | | G | 3 |
G | 3 | | H | 2 |
H | 2 | | I | 1 |
I | 1 | | | |
On est bientôt là ?
Edge Side Includes
Use Edge Side Includes
Comme les server side includes à l'epoque, mais dans le reverse proxy:
- URL dans le contenu vers des fragments à inclure
- Proxy cherche et cache les éléments séparament
- Donc caching individuel de chaque fragment
ESI Json
{
"total_hits":3,
"products":[
<esi:include src="/products/1071.json" />,
<esi:include src="/products/1305.json" />,
<esi:include src="/products/1311.json" />
]
}
TTL trés bas pour les listes, invalidation active que pour les entrées
ESI Json et Symfony
- Dans l'application, ce n'est plus du JSON valide
- Symfony CacheKernel peut gérer ESI
- Si vous utilisez un proxy ESI, envoyez l'entête
Surrogate-Capability: abc="ESI/1.0"
- Avec Varnish, il faut activer ESI pour les non-xml
+esi_disable_xml_check,+esi_ignore_other_elements
- SSL seulement avec la version payant de Varnish
ESI géstion des erreurs
- La resilience et ESI se combinent
- ESI spec esi:try, esi:attempt, esi:except
- Pas supporté dans Symfony cache, ni Varnish
- Varnish: VCL
Conclusions
Take-Aways
- Ne comptez pas exclusivement sur le cache
- Lisez les RFC Cache-Control
- Mettez la logique dans l'application plutôt que le proxy
- Mesurez avant et après une optimisation
Outils
- Il existe toute sorte de Varnish plugins (vmods) pour des besoins spécifiques
- En PHP sans framework, voir FOSHttpCache: Invalidation, integration tests avec Varnish
- En Symfony sans API Platform, FOSHttpCacheBundle peut vous aider
Merci beaucoup!
@dbu
Varnish ESI: Retry status 500
sub vcl_recv {
if (req.esi_level > 0) {
set req.http.doing-esi = "yes";
}
}
sub vcl_backend_response {
# retry backend errors during esi
if (bereq.http.doing-esi && beresp.status >= 500) {
# sleep between 1 and 30 ms to spread the load
vtc.sleep(std.duration(std.random(0.001,0.03), 0.02s));
return(retry);
}
}
Varnish ESI: Retry on error
sub vcl_backend_response {
if (bereq.http.doing-esi && beresp.http.Content-Length) {
# Disable streaming to validate the content-length
set beresp.do_stream = false;
}
}
sub vcl_backend_error {
# retry fetch errors during esi
# respect retry limit to still reach vcl_deliver
if (bereq.http.doing-esi && beresp.status == 503
&& bereq.retries < 4
) {
vtc.sleep(std.duration(std.random(0.001,0.03), 0.02s));
return(retry);
}
}
Varnish ESI: Replace error responses
sub vcl_deliver {
if (req.http.doing-esi && resp.status >= 400) {
return (synth(758, "Ignore ESI error"));
}
}
sub vcl_synth {
if (resp.status == 758) {
set resp.http.Content-Type =
"application/json; charset=utf-8";
set resp.status = 200;
# need space after " before } to not confuse Varnish
synthetic({"{"failed_product":""} + req.url + {"" }"});
std.syslog(3, "ESI kept failing for "+req.url);
return (deliver);
}
}