Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle ActionDispatch::Response::RackBody as body #22

Open
wants to merge 13 commits into
base: main
Choose a base branch
from

Conversation

skatkov
Copy link
Contributor

@skatkov skatkov commented Jun 26, 2024

With our new rails 7.1 project, idempotent requests don't seem to be working. They are not even saved.

As it turns out, Idempo middleware receives an instance of ActionDispatch::Response::RackBody as a body. And since it's not Array, we're not saving that body.

I'm not sure exactly why another project based on rails 7.0 keeps working. Because rails started returning ActionDispatch::Response::RackBody pre 7.0 version. I assume, that this could be due to the way how middleware is being inserted (how deep in a stack).

Here is what we do in our application.rb:

config.middleware.insert_after ActionDispatch::Executor, Idempo, backend: Idempo::ActiveRecordBackend.new

And here is an output of rails middleware:

use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use Idempo
use ActionDispatch::ServerTiming
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
use ActionDispatch::Static
run Banksvc::Application.routes

But that is just a wild guess.

As a fix, I suggest trying converting body to array — this proved effective in our project.

lib/idempo.rb Outdated Show resolved Hide resolved
@franzliedke
Copy link
Collaborator

Whew, I got quite scared when I read the gem is not working for you with Rails 7.1. In our project (also Rails 7.1), it does.

We insert the middleware directly in the base controller (a barely-known Rails feature), can you try that out to get closer to the root cause? (You hinted middleware order may play a role.)

class MyRailsController
  use Idempo, backend: Idempo::ActiveRecordBackend.new, other: options
end

@julik
Copy link
Owner

julik commented Sep 18, 2024

I am inclined to merge this with a few changes (only relying on to_ary if we are, in fact, under Rack 3 or greater). to_ary will materialize the entire response into memory and the body can then be handled as an array of strings, in the +- usual manner, but there have been cases with various libraries implementing to_ary on Rack response bodies incorrectly. @franzliedke you have any objections to this?

@franzliedke
Copy link
Collaborator

No big objections, haven't peeked into the insides of Rack here. Is there any way we can add some regression tests around this?

@skatkov
Copy link
Contributor Author

skatkov commented Sep 19, 2024

@julik > only relying on to_ary if we are, in fact, under Rack 3 or greater

I'll make a change.

@skatkov skatkov marked this pull request as draft September 19, 2024 07:58
@skatkov skatkov force-pushed the handle-rack-body-object branch 2 times, most recently from 449bdc8 to 94d9221 Compare September 19, 2024 14:33
@skatkov
Copy link
Contributor Author

skatkov commented Sep 19, 2024

@julik Did couple of adjustments.

  • Body will be converted to array only for rack 3.
  • Ensured that we keep testing rack v2 as well

@skatkov skatkov requested a review from julik September 19, 2024 15:26
@skatkov skatkov marked this pull request as ready for review September 19, 2024 15:26
Copy link
Owner

@julik julik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few tweaks still needed. Also maybe a good idea to note around rack_response_body.close that this will not be applied to Arrays resulting from to_ary

@@ -58,6 +59,12 @@ def call(env)

expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i
if response_may_be_persisted?(status, headers, body)
if Gem::Version.new(Rack.release) >= Gem::Version.new("3.0")
# `body` could be of type `ActionDispatch::Response::RackBody` and idempo will not even attempt to store it,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's alter this comment because it is a bit misleading. A Rack response body may permit "materialization" into an Array of strings using to_ary - this is in fact in the Rack spec, here https://github.com/rack/rack/blob/main/SPEC.rdoc#the-body- So in this case, checking for respond_to?(:to_ary) and calling it is sufficient. For each and call we might need more special cases, but in this instance it is enough to state that some Rack bodies can be "expanded" using to_ary. It doesn't matter whether such a body comes from Rails or any other callee. It is also important to note that if we did use to_ary we are not supposed to call close - i.e. we need to stick to the Rack semantics very carefully here, and the comments should reflect that.

if Gem::Version.new(Rack.release) >= Gem::Version.new("3.0")
# `body` could be of type `ActionDispatch::Response::RackBody` and idempo will not even attempt to store it,
# we're converting ActionDispatch::Response::RackBody to a storable array format.
body = body.try(:to_ary) || body if !body.is_a?(Array) && !body.is_a?(Enumerator)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for any extra checkes except for the responding to to_ary. An Array will respond to to_ary and return self:

[2] pry(main)> ar = []
=> []
[3] pry(main)> ar.object_id
=> 320
[4] pry(main)> ar.to_ary.object_id
=> 320

A Body being an Enumerator is another check that should not be done, as an object can perfectly be a Rack response body and respond to either each or call, but not be an Enumerator.

@@ -58,6 +59,12 @@ def call(env)

expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i
if response_may_be_persisted?(status, headers, body)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most likely you want to call to_ary here, actually - because an Array of strings may be persistable, and replacing body with the "materialized" array will allow response_may_be_persisted? to size it correctly by a sum of bytesizes of the array elements. So first call to_ary to materialize, then check for whether the materialized body is too big.

@@ -117,7 +124,7 @@ def response_may_be_persisted?(status, headers, body)
def body_size_within_limit?(response_headers, body)
return response_headers["Content-Length"].to_i <= SAVED_RESPONSE_BODY_SIZE_LIMIT if response_headers["Content-Length"]

return false unless body.is_a?(Array) # Arbitrary iterable of unknown size
return false unless body.is_a?(Array) || body.respond_to?(:to_ary) # Arbitrary iterable of unknown size
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement won't be needed if you materialize into an Array earlier

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants