From 0b15e2d600bc9ea2c106f67eac6d7567813326ac Mon Sep 17 00:00:00 2001 From: Matthew Hutchinson Date: Tue, 24 Mar 2026 11:39:12 +0000 Subject: [PATCH] Implement stale-while-revalidate cache control, with a configurable hook to trigger refresh --- CHANGELOG.md | 3 ++ README.md | 33 +++++++++++++- examples/stale_while_revalidate.rb | 58 +++++++++++++++++++++++++ lib/faraday/http_cache.rb | 20 ++++++++- lib/faraday/http_cache/cache_control.rb | 7 +++ lib/faraday/http_cache/response.rb | 19 ++++++++ spec/cache_control_spec.rb | 10 +++++ spec/http_cache_spec.rb | 55 +++++++++++++++++++++++ spec/instrumentation_spec.rb | 10 +++++ spec/response_spec.rb | 23 ++++++++++ spec/support/test_app.rb | 12 +++++ 11 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 examples/stale_while_revalidate.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 459ab21..9728248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index a52f1f3..be7a669 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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| @@ -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') @@ -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? @@ -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 diff --git a/examples/stale_while_revalidate.rb b/examples/stale_while_revalidate.rb new file mode 100644 index 0000000..6384f13 --- /dev/null +++ b/examples/stale_while_revalidate.rb @@ -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}" diff --git a/lib/faraday/http_cache.rb b/lib/faraday/http_cache.rb index 2ad0a66..9d0586a 100644 --- a/lib/faraday/http_cache.rb +++ b/lib/faraday/http_cache.rb @@ -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, @@ -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. @@ -103,7 +108,7 @@ 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 @@ -111,6 +116,7 @@ def initialize(app, options = {}) @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 } @@ -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) @@ -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. diff --git a/lib/faraday/http_cache/cache_control.rb b/lib/faraday/http_cache/cache_control.rb index 05d546c..acb0349 100644 --- a/lib/faraday/http_cache/cache_control.rb +++ b/lib/faraday/http_cache/cache_control.rb @@ -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 '=' diff --git a/lib/faraday/http_cache/response.rb b/lib/faraday/http_cache/response.rb index 70d2654..d7eeb19 100644 --- a/lib/faraday/http_cache/response.rb +++ b/lib/faraday/http_cache/response.rb @@ -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. @@ -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. # diff --git a/spec/cache_control_spec.rb b/spec/cache_control_spec.rb index f9f2088..01c010b 100644 --- a/spec/cache_control_spec.rb +++ b/spec/cache_control_spec.rb @@ -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 diff --git a/spec/http_cache_spec.rb b/spec/http_cache_spec.rb index 02f7e39..0021173 100644 --- a/spec/http_cache_spec.rb +++ b/spec/http_cache_spec.rb @@ -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') diff --git a/spec/instrumentation_spec.rb b/spec/instrumentation_spec.rb index fa3f6a8..58bdea1 100644 --- a/spec/instrumentation_spec.rb +++ b/spec/instrumentation_spec.rb @@ -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 = { diff --git a/spec/response_spec.rb b/spec/response_spec.rb index 0dc4e12..e14f644 100644 --- a/spec/response_spec.rb +++ b/spec/response_spec.rb @@ -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') } diff --git a/spec/support/test_app.rb b/spec/support/test_app.rb index cde97d9..9b492fa 100644 --- a/spec/support/test_app.rb +++ b/spec/support/test_app.rb @@ -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