ResponseBank

Features
- Serve gzip'd content
- Add ETag and 304 Not Modified headers
- Generational caching
- No explicit expiry
Support
This gem supports the following versions of Ruby and Rails:
- Ruby 3.1.0+
- Rails 6.0.0+
Usage
-
include the gem in your Gemfile
gem 'response_bank' -
add an initializer file. We need to configure the
acquire_lockmethod, set the cache store and the loggerrequire 'response_bank' module ResponseBank LOCK_TTL = 90 class << self def acquire_lock(cache_key) cache_store.write("#{cache_key}:lock", '1', unless_exist: true, expires_in: LOCK_TTL, raw: true) end end end ResponseBank.cache_store = ActiveSupport::Cache.lookup_store(Rails.configuration.cache_store) ResponseBank.logger = Rails.logger -
enables caching on your application
config.action_controller.perform_caching = true -
use
#response_cachemethod to any desired controller's actionclass PostsController < ApplicationController def show response_cache do @post = @shop.posts.find(params[:id]) respond_with(@post) end end end -
(optional) set a custom TTL for the cache by overriding the
write_to_backing_cache_storemethod in your initializer filemodule ResponseBank CACHE_TTL = 30.minutes def write_to_backing_cache_store(_env, key, payload, expires_in: nil) cache_store.write(key, payload, raw: true, expires_in: expires_in || CACHE_TTL) end end -
(optional) override custom cache key data. For default, cache key is defined by URL and query string
class PostsController < ApplicationController before_action :set_shop def index response_cache do @post = @shop.posts respond_with(@post) end end def show response_cache do @post = @shop.posts.find(params[:id]) respond_with(@post) end end def another_action # custom cache key data cache_key = { action: action_name, format: request.format, shop_updated_at: @shop.updated_at # you may add more keys here } response_cache cache_key do @post = @shop.posts.find(params[:id]) respond_with(@post) end end # override default cache key data globally per class def cache_key_data { action: action_name, format: request.format, params: params.slice(:id), shop_version: @shop.version # you may add more keys here } end def set_shop # @shop = ... end end
Completing a deferred cache miss
ResponseBank::Middleware can cache a body only when it is complete before the Rack tuple returns. An application that finishes a response later, such as through Rack partial hijack, can defer the cache write during a real ResponseBank miss:
deferred_store = ResponseBank.defer_store(env)
# Return the Rack response. Later, after the body was generated and written in full:
cache_headers = headers.dup
cache_headers.delete('Cache-Control') if cache_headers['Cache-Control'] == 'no-cache, no-store'
deferred_store.complete(headers: cache_headers, body: completed_body)
# Run this from the response-finished path. It is a no-op after completion.
deferred_store.abort
defer_store is a cache-fill lifecycle API. Call it only from a ResponseBank cache-miss path after ResponseCacheHandler initialized the request and recorded logical fill-lock ownership. It raises if the request is not a GET cache miss, if required cache context is absent, or if another deferred store is already registered.
The middleware arms the handle after the application returns the Rack tuple. It does not add an ETag to the live deferred response. complete copies and filters the final cache headers, adds the cached ETag and Content-Encoding, compresses the complete body, and writes the existing MessagePack cache format. It returns true when it stores and false when the request no longer owns an eligible fill.
Call complete only after the intended response was generated and written successfully. Never call it from a rescue or ensure path. Do not complete failed, timed-out, disconnected, or truncated responses. The body must be the shared cache representation and must not contain client-specific data added for the live response.
The headers passed to complete describe the cached representation. They can differ from headers already sent to the client. ResponseBank rejects final cache headers that contain private or no-store, and it does not mutate the supplied hash. It captures the cache timestamp when defer_store is called, before deferred rendering and compression.
abort is idempotent and releases an owned fill lock through ResponseBank.release_lock. Its default implementation is a no-op. The existing write_to_cache hook remains responsible for cleanup after a write attempt. An integration that releases those fills from write_to_cache should also implement release_lock for abandoned fills and failures that happen before the write hook. A key-only lock cannot prevent an old fill from releasing a replacement lock after its lease expires; integrations that need that guarantee must use owner tokens in their lock implementation.
Brotli Splice Slots
Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to ResponseBank::Middleware:
use ResponseBank::Middleware, ->(env) { HtmlMetadataInjector.new(env) }
Rails applications can configure the same builder through config.response_bank:
config.response_bank.brotli_splice_injector =
->(env) { HtmlMetadataInjector.new(env) }
The injector is optional. If it is not configured, ResponseBank uses the normal Brotli compression path. Applications own the concrete injector implementation because they know how to read their request-specific metadata.
Injectors may include ResponseBank::BrotliSpliceInjector to document the required methods:
class HtmlMetadataInjector
include ResponseBank::BrotliSpliceInjector
# The per-request value spliced in on cache hits (a 36-byte UUID).
TOKEN_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"
# BrotliSplice reserves the LAST 2 bytes of a slot as a fixed "\r\n" context
# suffix, so a slot must span 2 more bytes than its replaceable region and
# `replacement_length` always comes back as `slot length - 2`. We let those
# 2 bytes be a real "\r\n" placed after the tag, where a line break is
# harmless — the replaceable region is therefore `<uuid>">` (38 bytes).
CONTEXT_SUFFIX = "\r\n"
PLACEHOLDER_TAG = %(<meta name="shopify-y" content="#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX})
SLOT = %(#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX}) # 38 replaceable bytes + 2 reserved
def initialize(env)
@env = env
end
def prepare_response_bank_brotli_splice(body, _headers)
body_with_placeholder = body.sub("</head>", "#{PLACEHOLDER_TAG}</head>")
# Use a byte offset: BrotliSplice.encode addresses the slot by bytes.
offset = body_with_placeholder.b.index(TOKEN_PLACEHOLDER)
return unless offset
{
body: body_with_placeholder,
slots: [
{
name: "shopify_y",
offset: offset,
# Include the 2 bytes reserved for the context suffix, or the
# replacement below will be 2 bytes too long and silently dropped.
length: SLOT.bytesize,
},
],
}
end
def response_bank_brotli_splice_replacement(slot)
# Must return EXACTLY slot["replacement_length"] bytes (== slot length - 2).
# If it does not, ResponseBank skips the splice and serves the neutral
# placeholder, so guard the length rather than assuming it.
replacement = %(#{shopify_y}">)
return unless replacement.bytesize == slot.fetch("replacement_length")
replacement
end
def replace_response_bank_brotli_splice_placeholders(body, slots)
slots.reduce(body) do |current, slot|
replacement = response_bank_brotli_splice_replacement(slot)
next current unless replacement
offset = slot.fetch("html_placeholder_offset")
length = slot.fetch("html_placeholder_length")
suffix = slot.fetch("context_suffix", CONTEXT_SUFFIX)
# replacement + suffix must equal the original slot length (byteslice
# replaces `length` bytes), keeping the body byte-for-byte consistent.
current.byteslice(0, offset) + replacement + suffix +
current.byteslice(offset + length, current.bytesize)
end
end
private
def shopify_y
@env.fetch("HTTP_SHOPIFY_Y") # 36-byte UUID
end
end
prepare_response_bank_brotli_splice is used on cache writes. It returns HTML
containing a neutral placeholder and one slot describing that placeholder.
ResponseBank stores the slot metadata with the cached Brotli body. The slot's
offset and length are byte offsets/counts — BrotliSplice.encode
addresses the stream by bytes — so locate the placeholder on a binary view
(body.b.index(...)) and size it with bytesize. A plain String#index/size
silently breaks once any multi-byte UTF-8 precedes the placeholder: a character
offset is always ≤ the byte length, so the bounds check still passes.
response_bank_brotli_splice_replacement is used on Brotli cache hits. It must
return exactly slot["replacement_length"] bytes. Note that replacement_length
is 2 fewer than the length you registered in the slot: BrotliSplice.encode
reserves the last 2 bytes of every slot as a fixed \r\n context suffix. So size
your placeholder to include those 2 bytes (as the example does with the trailing
\r\n), and have the replacement match replacement_length. If the byte length
does not match, ResponseBank silently skips the splice and serves the neutral
placeholder — no exception is raised — so guard the length instead of assuming it.
replace_response_bank_brotli_splice_placeholders is used when a cached Brotli response is decompressed for a client that does not accept Brotli.
Advanced integrations can still install the per-request injector directly in the Rack env before ResponseBank reads or writes the cached body:
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector
Exception Handling
ResponseBank handles all exceptions gracefully during cache operations. If an exception occurs while reading from cache, deserializing cached data, or writing to cache, the middleware will:
- On cache read failures: Fall back to rendering the page normally (as if it was a cache miss).
- On cache write failures: Still serve the successfully rendered page to the user, but log the cache write failure.
This ensures that issues with cache stores (Redis/Memcached down), serialization errors, or compression/decompression failures don't cause 500 errors for your users.
Custom Exception Handlers
You can set a custom exception handler in the Rack environment to be notified when cache operations fail (e.g., to report to Bugsnag, Sentry, etc.):
# In an initializer or middleware
class MyMiddleware
def call(env)
env['response_bank.on_exception'] = ->(e) { Bugsnag.notify(e) }
@app.call(env)
end
end
License
ResponseBank is released under the MIT License.