Search code examples
restcachingamazon-cloudfront

How to cache an API on Cloudfront with different strategies


I have multiple endpoints, some are relative to a given user while others are global.

I want to cache all of them but I'm struggling with the strategy.

GET /me

should be cached but different for each user. So I can cache with a combination of URI + Access token provided in the Authorization header.

GET /products/1

should be cached and is the same for everyone. But it needs the Authorization header as well to be accessed. So I can cache only the URI.

How to implement this behavior on a unique CloudFront distribution ? It seems that you can have only one caching combination.

Thanks,


Solution

  • CloudFront can cache on multiple cache key combinations based on different path patterns. Each cache behavior has a specific path pattern that it will match -- like /products/* -- and then there's a default cache behavior for * that matches anything else. (This one is created by default, and can't be removed). CloudFront distributions support up to 25 unique path patterns, each. It may be possible to have AWS Support increase that limit, but since each path pattern supports * as well as ? wildcards, and there's a default that catches everything else, that's probably sufficient.

    CloudFront works from the base assumption -- with limited exceptions -- that anything forwarded to the origin may cause the origin to vary the response. So, almost everything is stripped from the original request, by default.

    The User-Agent, for example, is set to Amazon CloudFront before the request is sent to the origin. Why? Because if the User-Agent were allowed to pass through, the origin might modify the content based on analysis of the user-agent string, such as to identify the device type (e.g. desktop, mobile, tablet, smart-tv) and respond accordingly. CloudFront has no way of knowing, before-hand, what the origin server will do with those values. But, if you need CloudFront to assume that changing the user-agent might change the response, CloudFront will also need to cache a unique copy of each object for every single user-agent string it sees, and use those cached copies only to match another identical request. You can whitelist the User-Agent header for forwarding to the origin, and that's what happens: CloudFront then sends that header along with every request, and also adds User-Agent to the cache key -- which is the collection of things, always including the request path, and always including whitelisted headers that CloudFront can use to uniquely identify any future request that it should consider truly identical.¹

    Cookies and query string parameters also can cause the origin server to modify its response, so these are also stripped from the request, by default. You can specify which cookies, or all cookies, or none (default). You can specify which query string parameters, or all query string parameters, or none (default). Whatever you specify is added to the cache key, and forwarded to the origin, and CloudFront will only serve a cached response that matches the entire cache key, exactly.

    The Authorization header is a particularly interesting example of this, because it seems you have spotted one issue, but perhaps overlooked another that's very important.

    In the case of GET /me -- each unique user (identified by Authorization) submitting a request gets a different response. The cache behavior settings for the path /me need to whitelist the Authorization header. Easy enough.

    But what about GET /products/1? Here be dragons. You still have to forward the Authorization header to the origin, because CloudFront otherwise doesn't actually know whether it's a valid, authorized request. Even though intuition suggests a cached response could be used, since every authorized user should receive the same response... CloudFront can't do that, because you need the origin to validate whether it's okay to respond favorably to that particular Authorization header. It has to be sent to the origin, which means it has to be part of the cache key. Every unique and valid Authorization header value results in CloudFront fetching and caching a new copy of the response we hoped to reuse. It will actually only be reused if exactly the same user requests it again, with an identical Authorization header.

    But, CloudFront has a potential solution for the case where we need to authenticate/authorize the request based on some attribute of the request, but we don't want to dilute the cache hit potential by forwarding Authorization headers or cookies to the origin, thus adding them to the cache key.

    Lambda@Edge is a CloudFront enhancement that allows you to intercept, inspect, and potentially modify requests and responses at 4 strategic points in the CloudFront signal flow -- on the request side, both before and after the cache is checked, and on the response side, before the cache is written to and before the final response (whether hit or miss) is returned to the viewer. The HTTP request and/or response is transformed to a JavaScript data structure, and your custom Node.js "trigger" code can modify CloudFront's behavior.

    In your situation, a Lambda@Edge viewer request trigger seems like a solution.

    The viewer request trigger has access to the original request, including the headers and cookies and query parameters that CloudFront will be stripping out because they aren't part of the cache key.

    Right here, for /products/*, you embed the logic in the trigger function code to validate the Authorization header. You assign the trigger function to the /products/* cache behavior.

    If Authorization is valid, you let the request through and return control to CloudFront, where it will be served from the cache if available, otherwise requested from your origin server -- but without the Authorization header present, because for these paths, you don't forward it, so it's not in the cache key.³ Your responses are now cacheable and reusable.

    If the Authorization header isn't valid, you generate your rejection response directly in the trigger code and CloudFront returns your response to the unauthorized requester, doing no cache check for the object.

    But how do you validate the Authorization header from within the trigger function? That depends on how your platform works. If it's JWT, you can validate it right in the function code. But the Lambda@Edge environment has access to the Internet, separate from the request CloudFront happens to be processing at the moment, so one option might be an HTTP request directly to your server. Another might involve a lookup you send to a service like DynamoDB. This is highly implementation specific.

    Lambda@Edge functions run in reusable containers. While reuse is not guaranteed, observation suggests substantial reuse occurs, so caching the results of the Authorization header lookups in memory in a global object can be expected to have a high hit rate.

    The tradeoff, of course, is whether the cost, complexity, and added latency of this solution outweighs the benefit of lower resource use and reduced latency that can result from potentially high cache hit rates.


    ¹ There are so many possible user-agent values out there, that User-Agent is usually a really bad choice for forwarding to the origin (and thus including in the cache key) so the CloudFront team came up with a solution for this specific case. CloudFront can analyze the user-agent for you, and categorize the browser as desktop, mobile non-tablet, mobile tablet, or smart TV, and instead of whitelisting User-Agent, you can whitelist one or more of the CloudFront-Is-*-Viewer headers... resulting in the ability to deliver responses based on the general class of browser being used, and dramatically improving cache hit rates, which abysmally low if User-Agent is forwarded. Instead of the user-agent string becoming part of the cache key, these browser categorization headers become part of the cache key, resulting in only 4 unique combinations. (Theoretically since each value is boolean, there are 15 possible combinations assuming all-false is impossible, but I've never encountered more than 4.)

    ² There's one more option for query string parameters, which is "forward all, cache based on whitelist." This is an exception to the general rule that everything sent to the origin is part of the cache key. A similar option is not available for request headers or cookies.

    ³ So how do you know the request is something your origin should process, since it's not going to have an Authorization header? You inject a custom origin header inside CloudFront that contains a secret, static key, known only to your origin and CloudFront, establishing trust.