Skip to content

Commit 0b15e2d

Browse files
Implement stale-while-revalidate cache control, with a configurable hook to trigger refresh
1 parent 198a590 commit 0b15e2d

11 files changed

Lines changed: 247 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## Unreleased
22

3+
* Added support for `Cache-Control: stale-while-revalidate`.
4+
* Added `:on_stale` middleware callback hook to trigger custom background refresh logic when stale cached responses are served.
5+
36
## 2.5.1 (2024-01-16)
47

58
* Support headers passed in using string keys when Vary header is in a different case via #137 (thanks @evman182)

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,31 @@ client = Faraday.new do |builder|
7272
end
7373
```
7474

75+
### Stale-While-Revalidate and background refresh hooks
76+
77+
The middleware supports `stale-while-revalidate` directives from the `Cache-Control` header.
78+
When a cached response is stale but still inside the `stale-while-revalidate` window, the middleware
79+
will serve the stale response immediately.
80+
81+
You can provide an `:on_stale` callback to trigger your own asynchronous refresh logic:
82+
83+
```ruby
84+
client = Faraday.new do |builder|
85+
builder.use :http_cache,
86+
store: Rails.cache,
87+
on_stale: lambda { |request:, env:, cached_response:|
88+
RefreshApiCacheJob.perform_later(request.url.to_s)
89+
}
90+
builder.adapter Faraday.default_adapter
91+
end
92+
```
93+
94+
The callback receives:
95+
96+
- `request`: `Faraday::HttpCache::Request`
97+
- `env`: current `Faraday::Env`
98+
- `cached_response`: `Faraday::HttpCache::Response`
99+
75100
### Strategies
76101

77102
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
140165
- `:valid` means that the cached response *could* be validated against the server.
141166
- `:fresh` means that the cached response was still fresh and could be returned without even
142167
calling the server.
168+
- `:stale` means that the cached response was stale, but served while inside
169+
`stale-while-revalidate` window.
143170

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

156183
case cache_status
157-
when :fresh, :valid
184+
when :fresh, :valid, :stale
158185
statsd.increment('api-calls.cache_hits')
159186
when :invalid, :miss
160187
statsd.increment('api-calls.cache_misses')
@@ -168,6 +195,7 @@ end
168195

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

172200
## What gets cached?
173201

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

182210
### Cache-Control
183211

184-
The `max-age`, `must-revalidate`, `proxy-revalidate` and `s-maxage` directives are checked.
212+
The `max-age`, `must-revalidate`, `proxy-revalidate`, `s-maxage` and
213+
`stale-while-revalidate` directives are checked.
185214

186215
### Shared vs. non-shared caches
187216

examples/stale_while_revalidate.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
require 'rubygems'
2+
require 'bundler/setup'
3+
4+
require 'faraday/http_cache'
5+
require 'active_support'
6+
require 'active_support/logger'
7+
8+
backend = Faraday::Adapter::Test::Stubs.new
9+
upstream_version = 1
10+
refresh_threads = []
11+
12+
client = nil
13+
14+
on_stale = lambda do |request:, env:, cached_response:|
15+
request && env && cached_response
16+
17+
puts " stale cache hit for #{request.url.path}, scheduling refresh"
18+
19+
refresh_threads << Thread.new do
20+
client.get(request.url.path, nil, 'Cache-Control' => 'no-cache')
21+
end
22+
end
23+
24+
client = Faraday.new do |stack|
25+
stack.use :http_cache, logger: ActiveSupport::Logger.new($stdout), on_stale: on_stale
26+
stack.adapter :test, backend
27+
end
28+
29+
backend.get('/resource') do |env|
30+
if env.request_headers['If-None-Match'] == upstream_version.to_s
31+
[304, {}, '']
32+
else
33+
headers = {
34+
'Cache-Control' => 'public, max-age=0, stale-while-revalidate=60',
35+
'Date' => Time.now.httpdate,
36+
'ETag' => upstream_version.to_s
37+
}
38+
[200, headers, "upstream-version-#{upstream_version}"]
39+
end
40+
end
41+
42+
puts 'Request #1 (cache miss)'
43+
response = client.get('/resource')
44+
puts " body: #{response.body}"
45+
puts
46+
47+
upstream_version = 2
48+
49+
puts 'Request #2 (served stale + refresh runs in background)'
50+
response = client.get('/resource')
51+
puts " body: #{response.body}"
52+
puts
53+
54+
refresh_threads.each(&:join)
55+
56+
puts 'Request #3 (sees refreshed cache entry)'
57+
response = client.get('/resource')
58+
puts " body: #{response.body}"

lib/faraday/http_cache.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ class HttpCache < Faraday::Middleware
6060
# The response was cached and can still be used.
6161
:fresh,
6262

63+
# The response was stale but served while revalidating asynchronously.
64+
:stale,
65+
6366
# The response was cached and the server has validated it with a 304 response.
6467
:valid,
6568

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

109114
options = options.dup
110115
@logger = options[:logger]
111116
@shared_cache = options.delete(:shared_cache) { true }
112117
@instrumenter = options.delete(:instrumenter)
113118
@instrument_name = options.delete(:instrument_name) { EVENT_NAME }
119+
@on_stale = options.delete(:on_stale) || block
114120

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

@@ -194,6 +200,10 @@ def process(env)
194200
if entry.fresh? && !@request.no_cache?
195201
response = entry.to_response(env)
196202
trace :fresh
203+
elsif entry.stale_while_revalidate? && !@request.no_cache?
204+
response = entry.to_response(env)
205+
trace :stale
206+
on_stale(env, entry)
197207
else
198208
trace :must_revalidate
199209
response = validate(entry, env)
@@ -312,6 +322,14 @@ def create_request(env)
312322
Request.from_env(env)
313323
end
314324

325+
def on_stale(env, cached_response)
326+
return unless @on_stale
327+
328+
@on_stale.call(request: @request, env: env, cached_response: cached_response)
329+
rescue StandardError => e
330+
@logger&.warn("HTTP Cache: on_stale callback failed: #{e.class}: #{e.message}")
331+
end
332+
315333
# Internal: Logs the trace info about the incoming request
316334
# and how the middleware handled it.
317335
# This method does nothing if theresn't a logger present.

lib/faraday/http_cache/cache_control.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def proxy_revalidate?
6868
@directives['proxy-revalidate']
6969
end
7070

71+
# Internal: Gets the 'stale-while-revalidate' directive as an Integer.
72+
#
73+
# Returns nil if the 'stale-while-revalidate' directive isn't present.
74+
def stale_while_revalidate
75+
@directives['stale-while-revalidate'].to_i if @directives.key?('stale-while-revalidate')
76+
end
77+
7178
# Internal: Gets the String representation for the cache directives.
7279
# Directives are joined by a '=' and then combined into a single String
7380
# separated by commas. Directives with a 'true' value will omit the '='

lib/faraday/http_cache/response.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ def fresh?
5454
!cache_control.no_cache? && ttl && ttl > 0
5555
end
5656

57+
# Internal: Checks if the response is stale but can still be served while
58+
# revalidating in the background.
59+
#
60+
# Returns true when the response has exceeded freshness lifetime, but is
61+
# still inside the stale-while-revalidate window.
62+
def stale_while_revalidate?
63+
return false if cache_control.no_cache?
64+
return false unless ttl && stale_while_revalidate
65+
66+
ttl <= 0 && -ttl <= stale_while_revalidate
67+
end
68+
5769
# Internal: Checks if the Response returned a 'Not Modified' status.
5870
#
5971
# Returns true if the response status code is 304.
@@ -123,6 +135,13 @@ def max_age
123135
(expires && (expires - @now))
124136
end
125137

138+
# Internal: Gets the stale-while-revalidate value in seconds.
139+
#
140+
# Returns an Integer or nil.
141+
def stale_while_revalidate
142+
cache_control.stale_while_revalidate
143+
end
144+
126145
# Internal: Creates a new 'Faraday::Response', merging the stored
127146
# response with the supplied 'env' object.
128147
#

spec/cache_control_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,14 @@
106106
cache_control = Faraday::HttpCache::CacheControl.new('max-age=600')
107107
expect(cache_control).not_to be_no_cache
108108
end
109+
110+
it 'responds to #stale_while_revalidate with an integer when directive present' do
111+
cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60, stale-while-revalidate=300')
112+
expect(cache_control.stale_while_revalidate).to eq(300)
113+
end
114+
115+
it 'responds to #stale_while_revalidate with nil when directive absent' do
116+
cache_control = Faraday::HttpCache::CacheControl.new('public, max-age=60')
117+
expect(cache_control.stale_while_revalidate).to be_nil
118+
end
109119
end

spec/http_cache_spec.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,61 @@
221221
client.get('get')
222222
end
223223

224+
describe 'stale-while-revalidate' do
225+
let(:on_stale) { double('stale callback', call: nil) }
226+
let(:options) { { logger: logger, on_stale: on_stale } }
227+
228+
it 'serves stale cached responses within stale-while-revalidate window' do
229+
expect(client.get('stale-while-revalidate').body).to eq('1')
230+
231+
response = client.get('stale-while-revalidate')
232+
expect(response.body).to eq('1')
233+
expect(response.env[:http_cache_trace]).to eq([:stale])
234+
end
235+
236+
it 'invokes the on_stale callback with request, env and cached response' do
237+
client.get('stale-while-revalidate')
238+
239+
expect(on_stale).to receive(:call).with(
240+
request: an_instance_of(Faraday::HttpCache::Request),
241+
env: an_instance_of(Faraday::Env),
242+
cached_response: an_instance_of(Faraday::HttpCache::Response)
243+
)
244+
245+
client.get('stale-while-revalidate')
246+
end
247+
248+
it 'ignores on_stale callback errors and still serves stale response' do
249+
failing_callback = lambda do |request:, env:, cached_response:|
250+
request && env && cached_response
251+
raise 'boom'
252+
end
253+
254+
local_client = Faraday.new(url: ENV['FARADAY_SERVER']) do |stack|
255+
stack.use Faraday::HttpCache, logger: logger, on_stale: failing_callback
256+
adapter = ENV['FARADAY_ADAPTER']
257+
stack.headers['X-Faraday-Adapter'] = adapter
258+
stack.headers['Content-Type'] = 'application/x-www-form-urlencoded'
259+
stack.adapter adapter.to_sym
260+
end
261+
262+
local_client.get('stale-while-revalidate')
263+
expect(logger).to receive(:warn).with(/on_stale callback failed: RuntimeError: boom/)
264+
265+
response = local_client.get('stale-while-revalidate')
266+
expect(response.body).to eq('1')
267+
expect(response.env[:http_cache_trace]).to eq([:stale])
268+
end
269+
270+
it 'revalidates when stale-while-revalidate window has expired' do
271+
expect(client.get('stale-while-revalidate-expired').body).to eq('1')
272+
273+
response = client.get('stale-while-revalidate-expired')
274+
expect(response.body).to eq('1')
275+
expect(response.env[:http_cache_trace]).to eq(%i[must_revalidate valid store])
276+
end
277+
end
278+
224279
it 'sends the "Last-Modified" header on response validation' do
225280
client.get('timestamped')
226281
expect(client.get('timestamped').body).to eq('1')

spec/instrumentation_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@
4343
expect(events.last.payload.fetch(:cache_status)).to eq(:fresh)
4444
end
4545

46+
it 'is :stale if the cache entry is stale but can be served while revalidating' do
47+
backend.get('/hello') do
48+
[200, { 'Cache-Control' => 'public, max-age=0, stale-while-revalidate=60', 'Date' => Time.now.httpdate, 'Etag' => '123ABCD' }, '']
49+
end
50+
51+
client.get('/hello') # miss
52+
client.get('/hello') # stale
53+
expect(events.last.payload.fetch(:cache_status)).to eq(:stale)
54+
end
55+
4656
it 'is :valid if the cache entry can be validated against the upstream' do
4757
backend.get('/hello') do
4858
headers = {

spec/response_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,29 @@
199199
end
200200
end
201201

202+
describe 'stale while revalidate' do
203+
it 'is true when response is stale but inside stale-while-revalidate window' do
204+
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20', 'Date' => (Time.now - 70).httpdate }
205+
response = Faraday::HttpCache::Response.new(response_headers: headers)
206+
207+
expect(response).to be_stale_while_revalidate
208+
end
209+
210+
it 'is false when response is stale and outside stale-while-revalidate window' do
211+
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20', 'Date' => (Time.now - 90).httpdate }
212+
response = Faraday::HttpCache::Response.new(response_headers: headers)
213+
214+
expect(response).not_to be_stale_while_revalidate
215+
end
216+
217+
it 'is false when no-cache is set' do
218+
headers = { 'Cache-Control' => 'max-age=60, stale-while-revalidate=20, no-cache', 'Date' => (Time.now - 70).httpdate }
219+
response = Faraday::HttpCache::Response.new(response_headers: headers)
220+
221+
expect(response).not_to be_stale_while_revalidate
222+
end
223+
end
224+
202225
describe 'response unboxing' do
203226
subject { described_class.new(status: 200, response_headers: {}, body: 'Hi!', reason_phrase: 'Success') }
204227

0 commit comments

Comments
 (0)