From 7ab20bb37d7fd077dde01f48a148d35fcda37470 Mon Sep 17 00:00:00 2001 From: Andreas Heiberg Date: Fri, 6 Feb 2026 17:57:07 +0100 Subject: [PATCH 1/3] feat: rescue cache read exceptions gracefully Extract read_from_cache from try_to_serve_from_cache and wrap it in a rescue block so that exceptions during cache reads, deserialization, or decompression fall back to a normal cache miss instead of raising a 500. Add handle_cache_exception helper with support for a custom response_bank.on_exception callback (e.g. for error reporting to Bugsnag/Sentry). --- README.md | 23 ++++++++ lib/response_bank/response_cache_handler.rb | 31 +++++++++-- test/response_cache_handler_test.rb | 61 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index be88033..fb1720b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,29 @@ This gem supports the following versions of Ruby and Rails: end ``` +## 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: + +1. **On cache read failures**: Fall back to rendering the page normally (as if it was a cache miss). +2. **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.): + +```ruby +# 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](LICENSE.txt). diff --git a/lib/response_bank/response_cache_handler.rb b/lib/response_bank/response_cache_handler.rb index c841a46..4c923cf 100644 --- a/lib/response_bank/response_cache_handler.rb +++ b/lib/response_bank/response_cache_handler.rb @@ -82,18 +82,25 @@ def cacheable_info_dump end def try_to_serve_from_cache + response = read_from_cache + return response if response + + # No cache hit; this request cannot be handled from cache. + # Yield to the controller and mark for writing into cache. + refill_cache + end + + def read_from_cache # Etag unless @skip_browser_cache response = serve_from_browser_cache(entity_tag_hash, @env['HTTP_IF_NONE_MATCH']) return response if response end - response = serve_from_cache(cache_key_hash, @serve_unversioned ? "*" : entity_tag_hash, @cache_age_tolerance) - return response if response - - # No cache hit; this request cannot be handled from cache. - # Yield to the controller and mark for writing into cache. - refill_cache + serve_from_cache(cache_key_hash, @serve_unversioned ? "*" : entity_tag_hash, @cache_age_tolerance) + rescue => exception + handle_cache_exception(exception) + nil end def serve_from_browser_cache(entity_tag, if_none_match) @@ -204,5 +211,17 @@ def refill_cache @cache_miss_block.call end + + def handle_cache_exception(exception) + ResponseBank.log("Cache operation failed: #{exception.class} - #{exception.message}") + + if @env['response_bank.on_exception'] + begin + @env['response_bank.on_exception'].call(exception) + rescue => handler_exception + ResponseBank.log("Exception handler failed: #{handler_exception.class} - #{handler_exception.message}") + end + end + end end end diff --git a/test/response_cache_handler_test.rb b/test/response_cache_handler_test.rb index 6a01b80..4573d1a 100644 --- a/test/response_cache_handler_test.rb +++ b/test/response_cache_handler_test.rb @@ -298,4 +298,65 @@ def expect_page_rendered(cache_entry, content_encoding = 'br') body end + + def test_exception_during_cache_read_falls_back_to_refill + @cache_store.expects(:read).raises(StandardError.new("Cache store is down")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Cache operation failed")).once + + _, _, body = handler.run! + assert_equal('dynamic output', body) + assert_cache_miss(true, nil) + end + + def test_exception_during_deserialization_falls_back_to_refill + @cache_store.expects(:read).returns("invalid msgpack data") + MessagePack.expects(:load).raises(MessagePack::MalformedFormatError.new("invalid data")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Cache operation failed")).once + + _, _, body = handler.run! + assert_equal('dynamic output', body) + assert_cache_miss(true, nil) + end + + def test_exception_during_decompression_falls_back_to_refill + @cache_store.expects(:read).returns(page_cache_entry(true, 'br')) + controller.request.env['HTTP_ACCEPT_ENCODING'] = 'gzip' + ResponseBank.expects(:decompress).raises(Brotli::Error.new("decompression failed")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Cache operation failed")).once + + _, _, body = handler.run! + assert_equal('dynamic output', body) + assert_cache_miss(true, :anything) + end + + def test_custom_exception_handler_is_called + exception_handled = false + exception_message = nil + + controller.request.env['response_bank.on_exception'] = ->(e) { + exception_handled = true + exception_message = e.message + } + + @cache_store.expects(:read).raises(StandardError.new("Cache store is down")) + ResponseBank.stubs(:log) + handler.run! + + assert(exception_handled, "Custom exception handler should have been called") + assert_equal("Cache store is down", exception_message) + end + + def test_exception_in_custom_handler_is_caught + controller.request.env['response_bank.on_exception'] = ->(e) { raise "Handler itself failed" } + + @cache_store.expects(:read).raises(StandardError.new("Cache store is down")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Exception handler failed")).once + + _, _, body = handler.run! + assert_equal('dynamic output', body) + end end From 9c7781b562d8dc49318252697cfa3ef696bccaaa Mon Sep 17 00:00:00 2001 From: Andreas Heiberg Date: Fri, 6 Feb 2026 18:07:00 +0100 Subject: [PATCH 2/3] feat: rescue cache write exceptions gracefully Wrap the compression, serialization, and cache write path in the middleware with a begin/rescue so that failures don't cause 500 errors. On exception the Content-Encoding header is stripped and the original uncompressed response is served. The response_bank.on_exception callback is invoked if set. --- lib/response_bank/middleware.rb | 80 +++++++++++++++++++-------------- test/middleware_test.rb | 77 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 34 deletions(-) diff --git a/lib/response_bank/middleware.rb b/lib/response_bank/middleware.rb index afabb93..b360f79 100644 --- a/lib/response_bank/middleware.rb +++ b/lib/response_bank/middleware.rb @@ -34,43 +34,55 @@ def call(env) body.each { |part| body_string << part } end - body_compressed = nil - if body_string && body_string != "" - headers['Content-Encoding'] = content_encoding - env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers) - time = ResponseBank.measure do - body_compressed = ResponseBank.compress( - body_string, - content_encoding, - compression_level: env["cacheable.compression_level"], - ) + begin + body_compressed = nil + if body_string && body_string != "" + headers['Content-Encoding'] = content_encoding + env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers) + time = ResponseBank.measure do + body_compressed = ResponseBank.compress( + body_string, + content_encoding, + compression_level: env["cacheable.compression_level"], + ) + end + ResponseBank.log("Compression time: #{time}ms") + env["cacheable.compression_time"] = time end - ResponseBank.log("Compression time: #{time}ms") - env["cacheable.compression_time"] = time - end - cached_headers = headers.slice(*CACHEABLE_HEADERS) - # Store result - cache_data = [status, cached_headers, body_compressed, timestamp, env["cacheable.compression_level"]] - - ResponseBank.write_to_cache(env['cacheable.key']) do - payload = MessagePack.dump(cache_data) - ResponseBank.write_to_backing_cache_store( - env, - env['cacheable.unversioned-key'], - payload, - expires_in: env['cacheable.versioned-cache-expiry'], - ) - end + cached_headers = headers.slice(*CACHEABLE_HEADERS) + # Store result + cache_data = [status, cached_headers, body_compressed, timestamp, env["cacheable.compression_level"]] + + ResponseBank.write_to_cache(env['cacheable.key']) do + payload = MessagePack.dump(cache_data) + ResponseBank.write_to_backing_cache_store( + env, + env['cacheable.unversioned-key'], + payload, + expires_in: env['cacheable.versioned-cache-expiry'], + ) + end - # since we had to generate the compressed version already we may - # as well serve it if the client wants it - if body_compressed - if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding) - body = [body_compressed] - else - # Remove content-encoding header for response with compressed content - headers.delete('Content-Encoding') + # since we had to generate the compressed version already we may + # as well serve it if the client wants it + if body_compressed + if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding) + body = [body_compressed] + else + # Remove content-encoding header for response with compressed content + headers.delete('Content-Encoding') + end + end + rescue => exception + headers.delete('Content-Encoding') + ResponseBank.log("Failed to write to cache: #{exception.class} - #{exception.message}") + if env['response_bank.on_exception'] + begin + env['response_bank.on_exception'].call(exception) + rescue => handler_exception + ResponseBank.log("Exception handler failed: #{handler_exception.class} - #{handler_exception.message}") + end end end end diff --git a/test/middleware_test.rb b/test/middleware_test.rb index 1726005..f04fb59 100644 --- a/test/middleware_test.rb +++ b/test/middleware_test.rb @@ -430,4 +430,81 @@ def test_cache_hit_client assert_equal('client', @env['cacheable.store']) assert_equal('"etag_value"', headers['ETag']) end + + def test_exception_during_compression_does_not_fail_request + ResponseBank.expects(:compress).raises(StandardError.new("Compression failed")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Failed to write to cache")).once + + ware = ResponseBank::Middleware.new(method(:cacheable_app)) + result = ware.call(@env) + + # Should still return 200 OK with uncompressed body and no Content-Encoding + assert_equal(200, result[0]) + assert_nil(result[1]['Content-Encoding']) + assert_equal('Hi', result[2].join) + end + + def test_exception_during_serialization_does_not_fail_request + ResponseBank.expects(:compress).returns("compressed") + MessagePack.expects(:dump).raises(StandardError.new("Serialization failed")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Failed to write to cache")).once + + ware = ResponseBank::Middleware.new(method(:cacheable_app)) + result = ware.call(@env) + + # Should still return 200 OK with uncompressed body and no Content-Encoding + assert_equal(200, result[0]) + assert_nil(result[1]['Content-Encoding']) + assert_equal('Hi', result[2].join) + end + + def test_exception_during_cache_write_does_not_fail_request + ResponseBank.cache_store.expects(:write).raises(StandardError.new("Cache store is down")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Failed to write to cache")).once + + ware = ResponseBank::Middleware.new(method(:cacheable_app)) + result = ware.call(@env) + + # Should still return 200 OK + assert_equal(200, result[0]) + assert_equal('Hi', result[2].join) + end + + def test_custom_exception_handler_called_on_middleware_failure + exception_handled = false + exception_message = nil + + @env['response_bank.on_exception'] = ->(e) { + exception_handled = true + exception_message = e.message + } + + ResponseBank.expects(:compress).raises(StandardError.new("Compression failed")) + ResponseBank.stubs(:log) + + ware = ResponseBank::Middleware.new(method(:cacheable_app)) + result = ware.call(@env) + + assert(exception_handled, "Custom exception handler should have been called") + assert_equal("Compression failed", exception_message) + assert_equal(200, result[0]) + end + + def test_exception_in_middleware_custom_handler_is_caught + @env['response_bank.on_exception'] = ->(e) { raise "Handler itself failed" } + + ResponseBank.expects(:compress).raises(StandardError.new("Compression failed")) + ResponseBank.stubs(:log) + ResponseBank.expects(:log).with(includes("Exception handler failed")).once + + ware = ResponseBank::Middleware.new(method(:cacheable_app)) + result = ware.call(@env) + + # Should still return 200 OK even if exception handler fails + assert_equal(200, result[0]) + assert_equal('Hi', result[2].join) + end end From 504fa7c058b4acbc2f37825c9d2430b088d5376b Mon Sep 17 00:00:00 2001 From: Andreas Heiberg Date: Fri, 6 Feb 2026 18:07:21 +0100 Subject: [PATCH 3/3] refactor: unset Content-Encoding header only after compression succeeds --- lib/response_bank.rb | 4 ++-- lib/response_bank/middleware.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/response_bank.rb b/lib/response_bank.rb index 5cd6481..3d31430 100644 --- a/lib/response_bank.rb +++ b/lib/response_bank.rb @@ -13,8 +13,8 @@ class << self DEFAULT_BROTLI_COMPRESSION_LEVEL = 7 - DEFAULT_COMPRESSION_LEVEL = -> (_env, headers) { - case headers['Content-Encoding'] + DEFAULT_COMPRESSION_LEVEL = -> (env, _headers) { + case env['response_bank.server_cache_encoding'] when 'br' DEFAULT_BROTLI_COMPRESSION_LEVEL when 'gzip' diff --git a/lib/response_bank/middleware.rb b/lib/response_bank/middleware.rb index b360f79..3da411b 100644 --- a/lib/response_bank/middleware.rb +++ b/lib/response_bank/middleware.rb @@ -37,7 +37,6 @@ def call(env) begin body_compressed = nil if body_string && body_string != "" - headers['Content-Encoding'] = content_encoding env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers) time = ResponseBank.measure do body_compressed = ResponseBank.compress( @@ -48,6 +47,7 @@ def call(env) end ResponseBank.log("Compression time: #{time}ms") env["cacheable.compression_time"] = time + headers['Content-Encoding'] = content_encoding end cached_headers = headers.slice(*CACHEABLE_HEADERS) @@ -75,7 +75,7 @@ def call(env) end end rescue => exception - headers.delete('Content-Encoding') + headers.delete('Content-Encoding') if body_compressed ResponseBank.log("Failed to write to cache: #{exception.class} - #{exception.message}") if env['response_bank.on_exception'] begin