Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## Unreleased

* Added support for `Cache-Control: stale-while-revalidate`.
* Added `:on_stale` middleware callback hook to trigger custom background refresh logic when stale cached responses are served.

## 2.5.1 (2024-01-16)

* Support headers passed in using string keys when Vary header is in a different case via #137 (thanks @evman182)
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,31 @@ client = Faraday.new do |builder|
end
```

### Stale-While-Revalidate and background refresh hooks

The middleware supports `stale-while-revalidate` directives from the `Cache-Control` header.
When a cached response is stale but still inside the `stale-while-revalidate` window, the middleware
will serve the stale response immediately.

You can provide an `:on_stale` callback to trigger your own asynchronous refresh logic:

```ruby
client = Faraday.new do |builder|
builder.use :http_cache,
store: Rails.cache,
on_stale: lambda { |request:, env:, cached_response:|
RefreshApiCacheJob.perform_later(request.url.to_s)
}
builder.adapter Faraday.default_adapter
end
```

The callback receives:

- `request`: `Faraday::HttpCache::Request`
- `env`: current `Faraday::Env`
- `cached_response`: `Faraday::HttpCache::Response`

### Strategies

You can provide a `:strategy` option to the middleware to specify the strategy to use.
Expand Down Expand Up @@ -140,6 +165,8 @@ processes a request. In the event payload, `:env` contains the response Faraday
- `:valid` means that the cached response *could* be validated against the server.
- `:fresh` means that the cached response was still fresh and could be returned without even
calling the server.
- `:stale` means that the cached response was stale, but served while inside
`stale-while-revalidate` window.

```ruby
client = Faraday.new do |builder|
Expand All @@ -154,7 +181,7 @@ ActiveSupport::Notifications.subscribe "http_cache.faraday" do |*args|
statsd = Statsd.new

case cache_status
when :fresh, :valid
when :fresh, :valid, :stale
statsd.increment('api-calls.cache_hits')
when :invalid, :miss
statsd.increment('api-calls.cache_misses')
Expand All @@ -168,6 +195,7 @@ end

You can clone this repository, install its dependencies with Bundler (run `bundle install`) and
execute the files under the `examples` directory to see a sample of the middleware usage.
For stale-while-revalidate behavior with `:on_stale`, see `examples/stale_while_revalidate.rb`.

## What gets cached?

Expand All @@ -181,7 +209,8 @@ The middleware will use the following headers to make caching decisions:

### Cache-Control

The `max-age`, `must-revalidate`, `proxy-revalidate` and `s-maxage` directives are checked.
The `max-age`, `must-revalidate`, `proxy-revalidate`, `s-maxage` and
`stale-while-revalidate` directives are checked.

### Shared vs. non-shared caches

Expand Down
58 changes: 58 additions & 0 deletions examples/stale_while_revalidate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
require 'rubygems'
require 'bundler/setup'

require 'faraday/http_cache'
require 'active_support'
require 'active_support/logger'

backend = Faraday::Adapter::Test::Stubs.new
upstream_version = 1
refresh_threads = []

client = nil

on_stale = lambda do |request:, env:, cached_response:|
request && env && cached_response

puts " stale cache hit for #{request.url.path}, scheduling refresh"

refresh_threads << Thread.new do
client.get(request.url.path, nil, 'Cache-Control' => 'no-cache')
end
end

client = Faraday.new do |stack|
stack.use :http_cache, logger: ActiveSupport::Logger.new($stdout), on_stale: on_stale
stack.adapter :test, backend
end

backend.get('/resource') do |env|
if env.request_headers['If-None-Match'] == upstream_version.to_s
[304, {}, '']
else
headers = {
'Cache-Control' => 'public, max-age=0, stale-while-revalidate=60',
'Date' => Time.now.httpdate,
'ETag' => upstream_version.to_s
}
[200, headers, "upstream-version-#{upstream_version}"]
end
end

puts 'Request #1 (cache miss)'
response = client.get('/resource')
puts " body: #{response.body}"
puts

upstream_version = 2

puts 'Request #2 (served stale + refresh runs in background)'
response = client.get('/resource')
puts " body: #{response.body}"
puts

refresh_threads.each(&:join)

puts 'Request #3 (sees refreshed cache entry)'
response = client.get('/resource')
puts " body: #{response.body}"
20 changes: 19 additions & 1 deletion lib/faraday/http_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class HttpCache < Faraday::Middleware
# The response was cached and can still be used.
:fresh,

# The response was stale but served while revalidating asynchronously.
:stale,

# The response was cached and the server has validated it with a 304 response.
:valid,

Expand All @@ -84,6 +87,8 @@ class HttpCache < Faraday::Middleware
# :shared_cache - A flag to mark the middleware as a shared cache or not.
# :instrumenter - An instrumentation object that should respond to 'instrument'.
# :instrument_name - The String name of the instrument being reported on (optional).
# :on_stale - A Proc/lambda called with request:, env:, cached_response: when
# a stale response is served within stale-while-revalidate window.
# :logger - A logger object.
# :max_entries - The maximum number of entries to store per cache key. This option is only
# used when using the +ByUrl+ cache strategy.
Expand All @@ -103,14 +108,15 @@ class HttpCache < Faraday::Middleware
# # Initialize the middleware with a MemoryStore and logger
# store = ActiveSupport::Cache.lookup_store
# Faraday::HttpCache.new(app, store: store, logger: my_logger)
def initialize(app, options = {})
def initialize(app, options = {}, &block)
super(app)

options = options.dup
@logger = options[:logger]
@shared_cache = options.delete(:shared_cache) { true }
@instrumenter = options.delete(:instrumenter)
@instrument_name = options.delete(:instrument_name) { EVENT_NAME }
@on_stale = options.delete(:on_stale) || block

strategy = options.delete(:strategy) { Strategies::ByUrl }

Expand Down Expand Up @@ -194,6 +200,10 @@ def process(env)
if entry.fresh? && !@request.no_cache?
response = entry.to_response(env)
trace :fresh
elsif entry.stale_while_revalidate? && !@request.no_cache?
response = entry.to_response(env)
trace :stale
on_stale(env, entry)
else
trace :must_revalidate
response = validate(entry, env)
Expand Down Expand Up @@ -312,6 +322,14 @@ def create_request(env)
Request.from_env(env)
end

def on_stale(env, cached_response)
return unless @on_stale

@on_stale.call(request: @request, env: env, cached_response: cached_response)
rescue StandardError => e
@logger&.warn("HTTP Cache: on_stale callback failed: #{e.class}: #{e.message}")
end

# Internal: Logs the trace info about the incoming request
# and how the middleware handled it.
# This method does nothing if theresn't a logger present.
Expand Down
7 changes: 7 additions & 0 deletions lib/faraday/http_cache/cache_control.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ def proxy_revalidate?
@directives['proxy-revalidate']
end

# Internal: Gets the 'stale-while-revalidate' directive as an Integer.
#
# Returns nil if the 'stale-while-revalidate' directive isn't present.
def stale_while_revalidate
@directives['stale-while-revalidate'].to_i if @directives.key?('stale-while-revalidate')
end

# Internal: Gets the String representation for the cache directives.
# Directives are joined by a '=' and then combined into a single String
# separated by commas. Directives with a 'true' value will omit the '='
Expand Down
19 changes: 19 additions & 0 deletions lib/faraday/http_cache/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ def fresh?
!cache_control.no_cache? && ttl && ttl > 0
end

# Internal: Checks if the response is stale but can still be served while
# revalidating in the background.
#
# Returns true when the response has exceeded freshness lifetime, but is
# still inside the stale-while-revalidate window.
def stale_while_revalidate?
return false if cache_control.no_cache?
return false unless ttl && stale_while_revalidate

ttl <= 0 && -ttl <= stale_while_revalidate
end

# Internal: Checks if the Response returned a 'Not Modified' status.
#
# Returns true if the response status code is 304.
Expand Down Expand Up @@ -123,6 +135,13 @@ def max_age
(expires && (expires - @now))
end

# Internal: Gets the stale-while-revalidate value in seconds.
#
# Returns an Integer or nil.
def stale_while_revalidate
cache_control.stale_while_revalidate
end

# Internal: Creates a new 'Faraday::Response', merging the stored
# response with the supplied 'env' object.
#
Expand Down
10 changes: 10 additions & 0 deletions spec/cache_control_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,14 @@
cache_control = Faraday::HttpCache::CacheControl.new('max-age=600')
expect(cache_control).not_to be_no_cache
end

it 'responds to #stale_while_revalidate with an integer when directive present' do
cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60, stale-while-revalidate=300')
expect(cache_control.stale_while_revalidate).to eq(300)
end

it 'responds to #stale_while_revalidate with nil when directive absent' do
cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60')
expect(cache_control.stale_while_revalidate).to be_nil
end
end
55 changes: 55 additions & 0 deletions spec/http_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,61 @@
client.get('get')
end

describe 'stale-while-revalidate' do
let(:on_stale) { double('stale callback', call: nil) }
let(:options) { { logger: logger, on_stale: on_stale } }

it 'serves stale cached responses within stale-while-revalidate window' do
expect(client.get('stale-while-revalidate').body).to eq('1')

response = client.get('stale-while-revalidate')
expect(response.body).to eq('1')
expect(response.env[:http_cache_trace]).to eq([:stale])
end

it 'invokes the on_stale callback with request, env and cached response' do
client.get('stale-while-revalidate')

expect(on_stale).to receive(:call).with(
request: an_instance_of(Faraday::HttpCache::Request),
env: an_instance_of(Faraday::Env),
cached_response: an_instance_of(Faraday::HttpCache::Response)
)

client.get('stale-while-revalidate')
end

it 'ignores on_stale callback errors and still serves stale response' do
failing_callback = lambda do |request:, env:, cached_response:|
request && env && cached_response
raise 'boom'
end

local_client = Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
stack.use Faraday::HttpCache, logger: logger, on_stale: failing_callback
adapter = ENV['FARADAY_ADAPTER']
stack.headers['X-Faraday-Adapter'] = adapter
stack.headers['Content-Type'] = 'application/x-www-form-urlencoded'
stack.adapter adapter.to_sym
end

local_client.get('stale-while-revalidate')
expect(logger).to receive(:warn).with(/on_stale callback failed: RuntimeError: boom/)

response = local_client.get('stale-while-revalidate')
expect(response.body).to eq('1')
expect(response.env[:http_cache_trace]).to eq([:stale])
end

it 'revalidates when stale-while-revalidate window has expired' do
expect(client.get('stale-while-revalidate-expired').body).to eq('1')

response = client.get('stale-while-revalidate-expired')
expect(response.body).to eq('1')
expect(response.env[:http_cache_trace]).to eq(%i[must_revalidate valid store])
end
end

it 'sends the "Last-Modified" header on response validation' do
client.get('timestamped')
expect(client.get('timestamped').body).to eq('1')
Expand Down
10 changes: 10 additions & 0 deletions spec/instrumentation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@
expect(events.last.payload.fetch(:cache_status)).to eq(:fresh)
end

it 'is :stale if the cache entry is stale but can be served while revalidating' do
backend.get('/hello') do
[200, { 'Cache-Control' => 'public, max-age=0, stale-while-revalidate=60', 'Date' => Time.now.httpdate, 'Etag' => '123ABCD' }, '']
end

client.get('/hello') # miss
client.get('/hello') # stale
expect(events.last.payload.fetch(:cache_status)).to eq(:stale)
end

it 'is :valid if the cache entry can be validated against the upstream' do
backend.get('/hello') do
headers = {
Expand Down
23 changes: 23 additions & 0 deletions spec/response_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,29 @@
end
end

describe 'stale while revalidate' do
it 'is true when response is stale but inside stale-while-revalidate window' do
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20', 'Date' => (Time.now - 70).httpdate }
response = Faraday::HttpCache::Response.new(response_headers: headers)

expect(response).to be_stale_while_revalidate
end

it 'is false when response is stale and outside stale-while-revalidate window' do
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20', 'Date' => (Time.now - 90).httpdate }
response = Faraday::HttpCache::Response.new(response_headers: headers)

expect(response).not_to be_stale_while_revalidate
end

it 'is false when no-cache is set' do
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20, no-cache', 'Date' => (Time.now - 70).httpdate }
response = Faraday::HttpCache::Response.new(response_headers: headers)

expect(response).not_to be_stale_while_revalidate
end
end

describe 'response unboxing' do
subject { described_class.new(status: 200, response_headers: {}, body: 'Hi!', reason_phrase: 'Success') }

Expand Down
12 changes: 12 additions & 0 deletions spec/support/test_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ class TestApp < Sinatra::Base
[200, { 'Cache-Control' => 'max-age=200' }, increment_counter]
end

get '/stale-while-revalidate' do
[200, { 'Cache-Control' => 'max-age=0, stale-while-revalidate=120', 'Date' => Time.now.httpdate, 'ETag' => 'stale' }, increment_counter]
end

get '/stale-while-revalidate-expired' do
if env['HTTP_IF_NONE_MATCH'] == '1'
[304, {}, '']
else
[200, { 'Cache-Control' => 'max-age=0, stale-while-revalidate=1', 'Date' => settings.yesterday, 'ETag' => '1' }, increment_counter]
end
end

post '/delete-with-location' do
[200, { 'Location' => "#{request.base_url}/get" }, '']
end
Expand Down