Skip to content

Commit 1264452

Browse files
authored
Merge pull request rails#52274 from Shopify/http-cache
Prefer ETag over Last-Modified for `fresh_when` and `stale?` according to the HTTP specification
2 parents c79671c + fc76371 commit 1264452

File tree

11 files changed

+151
-19
lines changed

11 files changed

+151
-19
lines changed

actionpack/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Add `config.action_dispatch.strict_freshness`.
2+
3+
When set to `true`, the `ETag` header takes precedence over the `Last-Modified` header when both are present,
4+
as specificied by RFC 7232, Section 6.
5+
6+
Defaults to `false` to maintain compatibility with previous versions of Rails, but is enabled as part of
7+
Rails 8.0 defaults.
8+
9+
*heka1024*
10+
111
* Support `immutable` directive in Cache-Control
212

313
```ruby

actionpack/lib/action_dispatch/http/cache.rb

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ module Request
99
HTTP_IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE"
1010
HTTP_IF_NONE_MATCH = "HTTP_IF_NONE_MATCH"
1111

12+
mattr_accessor :strict_freshness, default: false
13+
1214
def if_modified_since
1315
if since = get_header(HTTP_IF_MODIFIED_SINCE)
1416
Time.rfc2822(since) rescue nil
@@ -34,19 +36,32 @@ def etag_matches?(etag)
3436
end
3537
end
3638

37-
# Check response freshness (`Last-Modified` and ETag) against request
38-
# `If-Modified-Since` and `If-None-Match` conditions. If both headers are
39-
# supplied, both must match, or the request is not considered fresh.
39+
# Check response freshness (`Last-Modified` and `ETag`) against request
40+
# `If-Modified-Since` and `If-None-Match` conditions.
41+
# If both headers are supplied, based on configuration, either `ETag` is preferred over `Last-Modified`
42+
# or both are considered equally. You can adjust the preference with
43+
# `config.action_dispatch.strict_freshness`.
44+
# Reference: http://tools.ietf.org/html/rfc7232#section-6
4045
def fresh?(response)
41-
last_modified = if_modified_since
42-
etag = if_none_match
46+
if Request.strict_freshness
47+
if if_modified_since
48+
etag_matches?(response.etag)
49+
elsif if_none_match
50+
not_modified?(response.last_modified)
51+
else
52+
false
53+
end
54+
else
55+
last_modified = if_modified_since
56+
etag = if_none_match
4357

44-
return false unless last_modified || etag
58+
return false unless last_modified || etag
4559

46-
success = true
47-
success &&= not_modified?(response.last_modified) if last_modified
48-
success &&= etag_matches?(response.etag) if etag
49-
success
60+
success = true
61+
success &&= not_modified?(response.last_modified) if last_modified
62+
success &&= etag_matches?(response.etag) if etag
63+
success
64+
end
5065
end
5166
end
5267

actionpack/lib/action_dispatch/railtie.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Railtie < Rails::Railtie # :nodoc:
2929
config.action_dispatch.request_id_header = ActionDispatch::Constants::X_REQUEST_ID
3030
config.action_dispatch.log_rescued_responses = true
3131
config.action_dispatch.debug_exception_log_level = :fatal
32+
config.action_dispatch.strict_freshness = false
3233

3334
config.action_dispatch.default_headers = {
3435
"X-Frame-Options" => "SAMEORIGIN",
@@ -69,6 +70,7 @@ class Railtie < Rails::Railtie # :nodoc:
6970

7071
ActionDispatch::Routing::Mapper.route_source_locations = Rails.env.development?
7172

73+
ActionDispatch::Http::Cache::Request.strict_freshness = app.config.action_dispatch.strict_freshness
7274
ActionDispatch.test_app = app
7375
end
7476
end

actionpack/test/controller/api/conditional_get_test.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "abstract_unit"
44
require "active_support/core_ext/integer/time"
55
require "active_support/core_ext/numeric/time"
6+
require "support/etag_helper"
67

78
class ConditionalGetApiController < ActionController::API
89
before_action :handle_last_modified_and_etags, only: :two
@@ -24,6 +25,7 @@ def handle_last_modified_and_etags
2425
end
2526

2627
class ConditionalGetApiTest < ActionController::TestCase
28+
include EtagHelper
2729
tests ConditionalGetApiController
2830

2931
def setup
@@ -55,4 +57,54 @@ def test_request_not_modified
5557
assert_predicate @response.body, :blank?
5658
assert_equal @last_modified, @response.headers["Last-Modified"]
5759
end
60+
61+
def test_if_none_match_is_asterisk
62+
@request.if_none_match = "*"
63+
get :one
64+
assert_response :not_modified
65+
end
66+
67+
def test_etag_matches
68+
@request.if_none_match = weak_etag([:foo, 123])
69+
get :one
70+
assert_response :not_modified
71+
end
72+
73+
def test_etag_precedence_over_last_modified
74+
with_strict_freshness(true) do
75+
# Not modified because the etag matches
76+
@request.if_modified_since = 5.years.ago.httpdate
77+
@request.if_none_match = weak_etag([:foo, 123])
78+
79+
get :one
80+
assert_response :not_modified
81+
82+
# stale because the etag doesn't match
83+
@request.if_none_match = weak_etag([:bar, 124])
84+
@request.if_modified_since = @last_modified
85+
86+
get :one
87+
assert_response :success
88+
end
89+
end
90+
91+
def test_both_should_be_used_when_strict_freshness_is_false
92+
with_strict_freshness(false) do
93+
# stale because the etag match but the last modified doesn't
94+
@request.if_modified_since = 5.years.ago.httpdate
95+
@request.if_none_match = weak_etag([:foo, 123])
96+
97+
get :one
98+
assert_response :ok
99+
end
100+
end
101+
102+
private
103+
def with_strict_freshness(value)
104+
old_value = ActionDispatch::Http::Cache::Request.strict_freshness
105+
ActionDispatch::Http::Cache::Request.strict_freshness = value
106+
yield
107+
ensure
108+
ActionDispatch::Http::Cache::Request.strict_freshness = old_value
109+
end
58110
end

actionpack/test/controller/render_test.rb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "abstract_unit"
44
require "controller/fake_models"
5+
require "support/etag_helper"
56

67
class TestControllerWithExtraEtags < ActionController::Base
78
self.view_paths = [ActionView::FixtureResolver.new(
@@ -666,6 +667,7 @@ def test_last_modified_with_custom_cache_control_headers
666667
class EtagRenderTest < ActionController::TestCase
667668
tests TestControllerWithExtraEtags
668669
include TemplateModificationHelper
670+
include EtagHelper
669671

670672
def test_strong_etag
671673
@request.if_none_match = strong_etag(["strong", "ab", :cde, [:f]])
@@ -738,15 +740,6 @@ def test_etag_reflects_implicit_template_digest
738740
assert_not_equal etag, @response.etag
739741
end
740742
end
741-
742-
private
743-
def weak_etag(record)
744-
"W/#{strong_etag record}"
745-
end
746-
747-
def strong_etag(record)
748-
%("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(record))}")
749-
end
750743
end
751744

752745
class NamespacedEtagRenderTest < ActionController::TestCase
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module EtagHelper
4+
def weak_etag(record)
5+
"W/#{strong_etag record}"
6+
end
7+
8+
def strong_etag(record)
9+
%("#{ActiveSupport::Digest.hexdigest(ActiveSupport::Cache.expand_cache_key(record))}")
10+
end
11+
end

guides/source/caching_with_rails.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,10 @@ class ProductsController < ApplicationController
656656
end
657657
```
658658

659+
When both `last_modified` and `etag` are set, behavior varies depending on the value of `config.action_dispatch.strict_freshness`.
660+
If set to `true`, only the `etag` is considered as specified by RFC 7232 section 6.
661+
If set to `false`, both are considered and the cache is considered fresh if both conditions are satisfied, as was the historical Rails behavior.
662+
659663
Sometimes we want to cache response, for example a static page, that never gets
660664
expired. To achieve this, we can use `http_cache_forever` helper and by doing
661665
so browser and proxies will cache it indefinitely.

guides/source/configuring.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Below are the default values associated with each target version. In cases of co
6060

6161
#### Default Values for Target Version 8.0
6262

63+
- [`config.action_dispatch.strict_freshness`](#config-action-dispatch-strict-freshness): `true`
6364
- [`config.active_support.to_time_preserves_timezone`](#config-active-support-to-time-preserves-timezone): `:zone`
6465

6566
#### Default Values for Target Version 7.2
@@ -2116,6 +2117,20 @@ Setting the value to `:none` configures Action Pack raise all exceptions.
21162117
| (original) | `true` |
21172118
| 7.1 | `:all` |
21182119

2120+
### `config.action_dispatch.strict_freshness`
2121+
2122+
Configures whether the `ActionDispatch::ETag` middleware should prefer the `ETag` header over the `Last-Modified` header when both are present in the response.
2123+
2124+
If set to `true`, when both headers are present only the `ETag` is considered as specificed by RFC 7232 section 6.
2125+
2126+
If set to `false`, when both headers are present, both headers are checked and both need to match for the response to be considered fresh.
2127+
2128+
| Starting with version | The default value is |
2129+
| --------------------- | --------------------- |
2130+
| (original) | `false` |
2131+
| 8.0 | `true` |
2132+
2133+
21192134
#### `ActionDispatch::Callbacks.before`
21202135

21212136
Takes a block of code to run before the request.

railties/lib/rails/application/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ def load_defaults(target_version)
343343
if respond_to?(:active_support)
344344
active_support.to_time_preserves_timezone = :zone
345345
end
346+
347+
if respond_to?(:action_dispatch)
348+
action_dispatch.strict_freshness = true
349+
end
346350
else
347351
raise "Unknown version #{target_version.to_s.inspect}"
348352
end

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_0.rb.tt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@
1616
# If `false`, `to_time` methods will convert to the local system UTC offset instead.
1717
#++
1818
# Rails.application.config.active_support.to_time_preserves_timezone = :zone
19+
20+
###
21+
# When both `If-Modified-Since` and `If-None-Match` are provided by the client
22+
# only consider `If-None-Match` as specified by RFC 7232 Section 6.
23+
# If set to `false` both conditions need to be satisfied.
24+
#++
25+
# Rails.application.config.action_dispatch.strict_freshness = true

0 commit comments

Comments
 (0)