Skip to content

Commit 868f024

Browse files
committed
Add HTTP caching feature (RFC 7234)
Implement a new :caching feature that stores and reuses HTTP responses according to RFC 7234 freshness and validation semantics. Only GET and HEAD responses are cached. Supports Cache-Control (max-age, no-cache, no-store), Expires, ETag / If-None-Match, and Last-Modified / If-Modified-Since for freshness checks and conditional revalidation. Ships with a default in-memory store. Custom stores can be passed via the store option. Closes #223.
1 parent 95a3a22 commit 868f024

File tree

10 files changed

+1504
-0
lines changed

10 files changed

+1504
-0
lines changed

.mutant.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ matcher:
2525
- HTTP::URI*
2626
- HTTP::Headers*
2727
- HTTP::Redirector*
28+
- HTTP::Features::Caching*
2829
- HTTP::Features::RaiseError*
2930
- HTTP::Base64*
3031
ignore:

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848

4949
### Added
5050

51+
- HTTP caching feature (`HTTP.use(:caching)`) that stores and reuses responses
52+
according to RFC 7234. Supports `Cache-Control` (`max-age`, `no-cache`,
53+
`no-store`), `Expires`, `ETag` / `If-None-Match`, and
54+
`Last-Modified` / `If-Modified-Since` for freshness checks and conditional
55+
revalidation. Ships with a default in-memory store; custom stores can be
56+
passed via `store:` option. Only GET and HEAD responses are cached. ([#223])
5157
- `HTTP.digest_auth(user:, pass:)` for HTTP Digest Authentication (RFC 2617 /
5258
RFC 7616). Automatically handles 401 challenges with digest credentials,
5359
supporting MD5, SHA-256, MD5-sess, and SHA-256-sess algorithms with

Steepfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ target :lib do
1212
library "singleton"
1313
library "socket"
1414
library "tempfile"
15+
library "time"
1516
library "timeout"
1617
library "securerandom"
1718
library "uri"

lib/http/feature.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def on_error(_request, _error); end
7878

7979
require "http/features/auto_inflate"
8080
require "http/features/auto_deflate"
81+
require "http/features/caching"
8182
require "http/features/digest_auth"
8283
require "http/features/instrumentation"
8384
require "http/features/logging"

lib/http/features/caching.rb

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# frozen_string_literal: true
2+
3+
require "time"
4+
5+
require "http/features/caching/entry"
6+
require "http/features/caching/in_memory_store"
7+
8+
module HTTP
9+
module Features
10+
# HTTP caching feature that stores and reuses responses according to
11+
# RFC 7234. Only GET and HEAD responses are cached. Supports
12+
# `Cache-Control`, `Expires`, `ETag`, and `Last-Modified` for freshness
13+
# checks and conditional revalidation.
14+
#
15+
# @example Basic usage with in-memory cache
16+
# HTTP.use(:caching).get("https://example.com/")
17+
#
18+
# @example With a shared store across requests
19+
# store = HTTP::Features::Caching::InMemoryStore.new
20+
# client = HTTP.use(caching: { store: store })
21+
# client.get("https://example.com/")
22+
#
23+
class Caching < Feature
24+
CACHEABLE_METHODS = Set.new(%i[get head]).freeze
25+
private_constant :CACHEABLE_METHODS
26+
27+
# The cache store instance
28+
#
29+
# @example
30+
# feature.store
31+
#
32+
# @return [#lookup, #store] the cache store
33+
# @api public
34+
attr_reader :store
35+
36+
# Initializes the Caching feature
37+
#
38+
# @example
39+
# Caching.new(store: InMemoryStore.new)
40+
#
41+
# @param store [#lookup, #store] cache store instance
42+
# @return [Caching]
43+
# @api public
44+
def initialize(store: InMemoryStore.new)
45+
@store = store
46+
end
47+
48+
# Wraps the HTTP exchange with caching logic
49+
#
50+
# Checks the cache before making a request. Returns a cached response
51+
# if fresh; otherwise adds conditional headers and revalidates. Stores
52+
# cacheable responses for future use.
53+
#
54+
# @example
55+
# feature.around_request(request) { |req| perform_exchange(req) }
56+
#
57+
# @param request [HTTP::Request]
58+
# @yield Executes the HTTP exchange
59+
# @yieldreturn [HTTP::Response]
60+
# @return [HTTP::Response]
61+
# @api public
62+
def around_request(request)
63+
return yield(request) unless cacheable_request?(request)
64+
65+
entry = store.lookup(request)
66+
67+
return yield(request) unless entry
68+
69+
return build_cached_response(entry, request) if entry.fresh?
70+
71+
response = yield(add_conditional_headers(request, entry))
72+
73+
return revalidate_entry(entry, response, request) if response.code == 304
74+
75+
response
76+
end
77+
78+
# Stores cacheable responses in the cache
79+
#
80+
# @example
81+
# feature.wrap_response(response)
82+
#
83+
# @param response [HTTP::Response]
84+
# @return [HTTP::Response]
85+
# @api public
86+
def wrap_response(response)
87+
return response unless cacheable_request?(response.request)
88+
return response unless cacheable_response?(response)
89+
90+
store_and_freeze_response(response)
91+
end
92+
93+
private
94+
95+
# Revalidate a cached entry with a 304 response
96+
# @return [HTTP::Response]
97+
# @api private
98+
def revalidate_entry(entry, response, request)
99+
entry.update_headers!(response.headers)
100+
entry.revalidate!
101+
build_cached_response(entry, request)
102+
end
103+
104+
# Store response in cache and return a new response with eagerly-read body
105+
# @return [HTTP::Response]
106+
# @api private
107+
def store_and_freeze_response(response)
108+
body_string = String(response)
109+
store.store(response.request, build_entry(response, body_string))
110+
111+
Response.new(
112+
status: response.code,
113+
version: response.version,
114+
headers: response.headers,
115+
proxy_headers: response.proxy_headers,
116+
body: body_string,
117+
request: response.request
118+
)
119+
end
120+
121+
# Build a cache entry from a response
122+
# @return [Entry]
123+
# @api private
124+
def build_entry(response, body_string)
125+
Entry.new(
126+
status: response.code,
127+
version: response.version,
128+
headers: response.headers.dup,
129+
proxy_headers: response.proxy_headers,
130+
body: body_string,
131+
request_uri: response.uri,
132+
stored_at: now
133+
)
134+
end
135+
136+
# Check whether this request method is cacheable
137+
# @return [Boolean]
138+
# @api private
139+
def cacheable_request?(request)
140+
CACHEABLE_METHODS.include?(request.verb)
141+
end
142+
143+
# Check whether this response is cacheable
144+
# @return [Boolean]
145+
# @api private
146+
def cacheable_response?(response)
147+
return false if response.status < 200
148+
return false if response.status >= 400
149+
150+
directives = parse_cache_control(response.headers)
151+
return false if directives.include?("no-store")
152+
153+
freshness_info?(response, directives)
154+
end
155+
156+
# Whether the response carries enough information to determine freshness
157+
# @return [Boolean]
158+
# @api private
159+
def freshness_info?(response, directives)
160+
return true if directives.any? { |d| d.start_with?("max-age=") }
161+
return true if response.headers.include?(Headers::EXPIRES)
162+
return true if response.headers.include?(Headers::ETAG)
163+
164+
response.headers.include?(Headers::LAST_MODIFIED)
165+
end
166+
167+
# Parse Cache-Control header into a list of directives
168+
# @return [Array<String>]
169+
# @api private
170+
def parse_cache_control(headers)
171+
String(headers[Headers::CACHE_CONTROL]).downcase.split(",").map(&:strip)
172+
end
173+
174+
# Add conditional headers from a cached entry to the request
175+
# @return [HTTP::Request]
176+
# @api private
177+
def add_conditional_headers(request, entry)
178+
headers = request.headers.dup
179+
headers[Headers::IF_NONE_MATCH] = entry.headers[Headers::ETAG] # steep:ignore
180+
headers[Headers::IF_MODIFIED_SINCE] = entry.headers[Headers::LAST_MODIFIED] # steep:ignore
181+
182+
Request.new(
183+
verb: request.verb,
184+
uri: request.uri,
185+
headers: headers,
186+
proxy: request.proxy,
187+
body: request.body,
188+
version: request.version
189+
)
190+
end
191+
192+
# Build a response from a cached entry
193+
# @return [HTTP::Response]
194+
# @api private
195+
def build_cached_response(entry, request)
196+
Response.new(
197+
status: entry.status,
198+
version: entry.version,
199+
headers: entry.headers,
200+
proxy_headers: entry.proxy_headers,
201+
body: entry.body,
202+
request: request
203+
)
204+
end
205+
206+
# Current time (extracted for testability)
207+
# @return [Time]
208+
# @api private
209+
def now
210+
Time.now
211+
end
212+
213+
HTTP::Options.register_feature(:caching, self)
214+
end
215+
end
216+
end

0 commit comments

Comments
 (0)