Web applications may send a special HTTP method OPTIONS to query an API for functionality. If supported, the answer is a bunch of clear text HTTP headers. An OPTIONS request is usually quite lightweight for the server, but it still uses resources like connections and CPU time. The answer to an OPTIONS request seldom changes unless the API itself changes. So we have small HTTP text objects that seldom are updated. Sounds ideal for caching with Varnish.
Varnish is the ultimate HTTP cache. If you don’t know what Varnish is, think of it as an ultra fast caching web proxy. Powered by the dark side of the force. On steroids. It also has a rich configuration language, and programmable interface, making it ideal as a level 7 Swiss army knife. For 12 use cases of Varnish, see my Sysadvent post 12 days of varnish.
A customer asked us to start caching OPTIONS requests, observing a large amount of OPTIONS call to their API servers, as much of the normal GET requests were already cached in Varnish.
Varnish does not cache OPTIONS by default. From vcl_recv in the builtin VCL:
if (req.method != "GET" && req.method != "HEAD") {
/* We only deal with GET and HEAD by default */
return (pass);
}
if (req.http.Authorization || req.http.Cookie) {
/* Not cacheable by default */
return (pass);
}
return (hash);
Tip: Use varnishd -x builtin
to read the builtin VCL.
The functions in the builtin VCL are appended to the corresponding
functions in our custom VCL (i.e. /etc/varnish/default.vcl
), unless we
terminate the function with an explicit return. Which is what we need
here: As we see above, all methods except GET and HEAD get a return
(pass)
. But we want to return (hash)
also for OPTIONS. As the
builtin VCL can not be changed, we have to add an explicit return
(hash)
ourselves. Note that the builtin VCL is there for a reason, so
if we short-circuit it with a return, we should replicate the interesting
parts of the builtin VCL as well. We put this in a separate function
to avoid cluttering vcl_recv
too much.
sub method_options {
/* The built-in vcl_recv overrides pass on OPTIONS, but we want to
* run some parts of it. So we add stuff from built-in vcl_recv here
*/
if (!req.http.host &&
req.esi_level == 0 &&
req.proto ~ "^(?i)HTTP/1.1") {
/* In HTTP/1.1, Host is required. */
return (synth(400));
}
if (req.http.Authorization || req.http.Cookie) {
/* Not cacheable by default */
return (pass);
}
/* Done with stuff from built-in vcl */
/* Save the original method (ie. OPTIONS) for later */
set req.http.X-OrigMethod = req.method;
/* Explicit return hash */
return (hash);
}
Note that we save the request’s method in a header X-OrigMethod
. More on
this later
Now we may call this function from vcl_recv
on an OPTIONS match:
sub vcl_recv {
// (...)
/* This should be added at the very end of vcl_recv, as it overrides
* the built-in vcl for OPTIONS
*/
if (req.method == "OPTIONS") { call method_options; }
}
Note that we do this as close to the very end of vcl_recv
as
possible to make sure that the builtin VCL runs for all other
requests.
Next, to avoid that cached GET and OPTIONS objects with the same URL get mixed, we add the method to the hash, so they are stored separately:
sub vcl_hash {
/* Hash on method to avoid getting OPTION output in GET requests
* and vica verca
*/
if (req.method == "OPTIONS" ) {
hash_data(req.method);
}
}
(We could drop the if test there, and add the method to the hash for all requests if we like.)
This should be enough to get caching of OPTIONS working. But it is
not. In their eternal wisdom, the Varnish developers convert an
OPTIONS request to a GET when the backend client starts
working. So we have to set it explicitly back in
vcl_backend_fetch
. Remember we saved the method in a header? It now
becomes handy:
sub vcl_backend_fetch {
/* Here be dragons: Varnish will automagically convert the OPTIONS
* to GET after hashing, so set it back */
if (bereq.http.X-OrigMethod == "OPTIONS") {
set bereq.method = bereq.http.X-OrigMethod;
}
}
With this last piece in place, varnish should cache OPTIONS
fine. Custom TTL may be set in vcl_backend_response
, for example like this:
sub vcl_backend_response {
if (bereq.http.x-method == "OPTIONS") {
set beresp.ttl = 1800s;
}
}
Use curl for easy testing:
curl -s -X OPTIONS -D - 'https://api.example.com/path/to/endpoint/v42/'
-D -
includes headers in the output to stdout. Look for the Age:
header. It should increase on repeated requests.
There is a final problem that we have not looked at: Purging content. A PURGE request does not include the original method for an object stored in the cache. To add support for purging both GET and OPTIONS object from the cache, use a magic header, something like this:
acl purge {
"localhost";
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return(synth(405,"Not allowed."));
}
/* Use a magic header X-Method for purging OPTIONS requests
* Like curl -H "X-Method: OPTIONS" -X PURGE ...
*/
if ( req.http.X-Method == "OPTIONS" ) {
set req.method = req.http.X-Method;
}
return (purge);
}
To request PURGE to an OPTIONS object in the cache, you may now do
curl -s -X PURGE -H "X-Method: OPTIONS" 'https://api.example.com/path/to/endpoint/v42/'
Conclusion: HTTP OPTIONS requests are well suited for caching. Varnish
will cache OPTIONS request well with a little configuration.
Redpill Linpro is the Open Source leader in the Nordics, helping customers with the digital transformation since back in the nineties.
Great thanks to Varnish Software for maintaining the Open Source 6.0 LTS branch of Varnish Cache. Also shout-out to Simon of one.com on IRC for patiently explaining me some of the difficult parts above.
Update
- 2024-02-19 Change credit link in header.