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?

httpstatusdogs.com

Aperçu




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

HTTP codes

twitter.com/stevelosh/status/372740571749572610




HTTP Cache Control

Cache control headers

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
            
  1. s-maxage
  2. max-age
  3. Expires (HTTP 1.0 - avoid!)
  4. Default to default_ttl if nothing specified

Cache validation

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
            

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


Supprimer des entrés dans le cache

Invalidation du cache

There are two hard things in computer science:

  1. Naming things
  2. Cache invalidation
  3. Off by one errors

Cache busting

<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

Varnish: Purge/Refresh, Ban, xkey

Cache Tagging avec Varnish

$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


FOSHttpCacheBundle




Comment gérer les listes?

Element  weight  <->  Element  weight
A9D22
B8A9
C7B8
D6C7
E5E5
F4F4
G3G3
H2H2
I1I1
Element  weight  <->  Element  weight
A9A9
B8B8
C7D6
D6E5
E5F4
F4G3
G3H2
H2I1
I1



On est bientôt là?




Edge Side Includes

Use Edge Side Includes

Comme les server side includes à l'epoque, mais dans le reverse proxy:

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

ESI géstion des erreurs




Conclusions

Take-Aways

Outils

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);
   }
}