« Back to Index

Simple Fastly CDN Rate Limiting Logic using Vary behaviour for cacheable GET requests

View original Gist on GitHub

Tags: #fastly #cdn

1. README

# Example:
# https://fiddle.fastlydemo.net/fiddle/4ac44faf
#
# the principle of this design is that we want 
# requests to be cached using a key that varies
# on the "Fastly-Client-IP" and "User-Agent" headers
# but only for responses that indicate "429 Too Many Requests"
#
# whenever we get a "429 Too Many Requests" response, we'll
# we'll ensure it is cacheable, and that will result in the Vary headers
# being applied as part of the cache lookup process (because a different
# client request will provide a different value for the IP and User-Agent headers
# which are used for the varying logic. and so they'll have empty values and 
# this will result in (hopefully) a cached "200 OK". or at the very least they'll 
# get a cache MISS and go to origin to fetch the content which will then be cached).
#
# caveat:
# this example doesn't work with POST requests.
# the solution to that is to `return(lookup)` in vcl_recv for POST|PUT|DELETE methods
# then skip caching for anything other than the 429 status code (e.g. if '200 OK' then don't cache)
# as we only want to cache a 429 with Vary behaviour
# 
# see this fiddle: https://fiddle.fastlydemo.net/fiddle/47871720
# which fixes the issue and implements the following changes 
# + additional changes to support otherwise uncacheable method types
#
# the code for that latter fiddle is also copied at the bottom of this gist
# just in case the fastly fiddle url is no longer available.
#
# one caveat to this approach (according to fastly)...
#
# > As far as the caveat is considered, Fastly have told me…
# > POST requests are also ineligible for clustering, ie transferring the request to a consistently-hashed storage node.  
# > Since we don’t expect POSTs to be cached, this is an optimisation and we unfortunately don’t provide a way for you to override it.
# > The effect of this is that even if you do enable POSTs to be cached, your cache hit ratio will be poor.
# > This may be fine if your intention is just to use it for rate limiting.
#
# So yeah it’s not the end of the world, but the fact we lose fastly’s clustering behaviour isn’t ideal either.

2. vcl_recv.vcl

if (req.restarts == 0) {
  # the User-Agent isn't provided by all clients (e.g. pentesting tools)
  # so we'll provide a genericized value if no header is found
  if (!req.http.User-Agent) {
    set req.http.UserAgent = "Foo-Bar";
  }
}

3. vcl_fetch.vcl

# at this point we've gone to the origin and we've received a 429
# and so we set the 429 to be cacheable and we give it a TTL via Cache-Control header
# and we state this content needs to utilize Vary so that other clients don't get the cached 429
# if no caching directives are provided, then we'll set a default of one hour
if (beresp.status == 429) {
  # ensure all responses have the required Vary header values
  # for the sake of this test fiddle we simply hardcode the value
  # but in our real service we would check the Vary header and append to it
  if (!beresp.http.Vary) {
    set beresp.http.Vary = "Fastly-Client-IP, User-Agent";
  }

  set beresp.cacheable = true;
  if (!beresp.http.Surrogate-Control && !beresp.http.Cache-Control) {
    set beresp.ttl = 1h; # 1 hour
  }
}

4. support all method types.vcl

sub vcl_recv {
  if (req.restarts == 0) {
    if (!req.http.User-Agent) {
      set req.http.UserAgent = "Fallback";
    }
  }

  // force caching for methods that are otherwise normally uncached
  // and mimic the standard behaviour of disabling 'request collapsing'
  if (req.method ~ "(POST|PUT|DELETE)") {
    set req.http.X-PassMethod = "true";
    set req.hash_ignore_busy = true;
    return(lookup);
  }
}
    
sub vcl_fetch {
  // this conditional is used for testing the logic
  // and isn't required for actual implementation
  if (req.http.X-429) {
    set beresp.status = 429;
  }

  declare local var.vary STRING;
  set var.vary = "Fastly-Client-IP, User-Agent";

  if (beresp.status == 429) {
    if (!beresp.http.Vary) {
      set beresp.http.Vary = var.vary;
    } else {
      set beresp.http.Vary = beresp.http.Vary + ", " + var.vary;
    }

    set beresp.cacheable = true;
    if (!beresp.http.Surrogate-Control:max-age && !beresp.http.Cache-Control:max-age) {
      set beresp.ttl = 1m; // 1 minute
    }
  }

  if (req.http.X-PassMethod) {
    if (beresp.status != 429) {
      set beresp.cacheable = false;
    }
  }
}