Skip to content

Commit 12151f9

Browse files
Improve Streamed Components retry logic (#1995)
Improve streamed components retry logic (#1995) Why - Previous implementation used dual HTTP connection pools to handle streaming vs non-streaming requests, doubling memory footprint. - Body duplication occurred when retries happened after partial chunks were already sent to the client, causing duplicate SSR-generated HTML in the rendered page. Summary - Refactored retry logic to use a single HTTPX connection pool with intelligent retry detection based on streaming state. - Replaced dual connection approach with request-level stream chunk tracking to prevent retries after first chunk is received. References - PR #1995 - Related: PR #1895 (original body duplication issue) - Related: PR #1900 (reverted dual connection approach)
1 parent 72f4dbc commit 12151f9

File tree

9 files changed

+163
-77
lines changed

9 files changed

+163
-77
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Changes since the last non-beta release.
6161

6262
- **Node Renderer Worker Restart**: Fixed "descriptor closed" error that occurred when the node renderer restarts while handling an in-progress request (especially streaming requests). Workers now perform graceful shutdowns: they disconnect from the cluster to stop receiving new requests, wait for active requests to complete, then shut down cleanly. A configurable `gracefulWorkerRestartTimeout` ensures workers are forcibly killed if they don't shut down in time. [PR 1970](https://github.com/shakacode/react_on_rails/pull/1970) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
6363

64+
- **Body Duplication Bug On Streaming**: Fixed a bug that happens while streaming if the node renderer connection closed after streaming some chunks to the client. [PR #1995](https://github.com/shakacode/react_on_rails/pull/1995) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
65+
6466
#### Breaking Changes
6567

6668
- **`config.immediate_hydration` configuration removed**: The `config.immediate_hydration` setting in `config/initializers/react_on_rails.rb` has been removed. Immediate hydration is now automatically enabled for React on Rails Pro users and automatically disabled for non-Pro users.

lib/react_on_rails/helper.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,15 @@ def server_rendered_react_component(render_options)
587587
# It doesn't make any transformation, it listens and raises error if a chunk has errors
588588
chunk_json_result
589589
end
590+
591+
result.rescue do |err|
592+
# This error came from the renderer
593+
raise ReactOnRails::PrerenderError.new(component_name: react_component_name,
594+
# Sanitize as this might be browser logged
595+
props: sanitized_props_string(props),
596+
err: err,
597+
js_code: js_code)
598+
end
590599
elsif result["hasErrors"] && render_options.raise_on_prerender_error
591600
raise_prerender_error(result, react_component_name, props, js_code)
592601
end

react_on_rails_pro/lib/react_on_rails_pro/request.rb

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ class Request # rubocop:disable Metrics/ClassLength
99
class << self
1010
def reset_connection
1111
@connection&.close
12-
@connection_without_retries&.close
1312
@connection = create_connection
14-
@connection_without_retries = create_connection(enable_retries: false)
1513
end
1614

1715
def render_code(path, js_code, send_bundle)
@@ -84,29 +82,17 @@ def asset_exists_on_vm_renderer?(filename)
8482

8583
private
8684

87-
# NOTE: We maintain two separate HTTP connection pools to handle streaming vs non-streaming requests.
88-
# This doubles the memory footprint (e.g., if renderer_http_pool_size is 10, we use 20 total connections).
89-
# This tradeoff is acceptable to prevent body duplication in streaming responses.
90-
9185
def connection
9286
@connection ||= create_connection
9387
end
9488

95-
def connection_without_retries
96-
@connection_without_retries ||= create_connection(enable_retries: false)
97-
end
98-
99-
def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
100-
# For streaming requests, use connection without retries to prevent body duplication
101-
# The StreamRequest class handles retries properly by starting fresh requests
102-
conn = post_options[:stream] ? connection_without_retries : connection
103-
89+
def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
10490
available_retries = ReactOnRailsPro.configuration.renderer_request_retry_limit
10591
retry_request = true
10692
while retry_request
10793
begin
10894
start_time = Time.now
109-
response = conn.post(path, **post_options)
95+
response = connection.post(path, **post_options)
11096
raise response.error if response.is_a?(HTTPX::ErrorResponse)
11197

11298
request_time = Time.now - start_time
@@ -231,20 +217,41 @@ def common_form_data
231217
ReactOnRailsPro::Utils.common_form_data
232218
end
233219

234-
def create_connection(enable_retries: true)
220+
def create_connection
235221
url = ReactOnRailsPro.configuration.renderer_url
236222
Rails.logger.info do
237223
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
238224
end
239225

240-
http_client = HTTPX
241-
# For persistent connections we want retries,
242-
# so the requests don't just fail if the other side closes the connection
243-
# https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent
244-
# However, for streaming requests, retries cause body duplication
245-
# See https://github.com/shakacode/react_on_rails/issues/1895
246-
http_client = http_client.plugin(:retries, max_retries: 1, retry_change_requests: true) if enable_retries
247-
http_client
226+
HTTPX
227+
# For persistent connections we want retries,
228+
# so the requests don't just fail if the other side closes the connection
229+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent
230+
.plugin(
231+
:retries, max_retries: 1,
232+
retry_change_requests: true,
233+
# Official HTTPx docs says that we should use the retry_on option to decide if teh request should be retried or not
234+
# However, HTTPx assumes that connection errors such as timeout error should be retried by default and it doesn't consider retry_on block at all at that case
235+
# So, we have to do the following trick to avoid retries when a Timeout error happens while streaming a component
236+
# If the streamed component returned any chunks, it shouldn't retry on errors, as it would cause page duplication
237+
# The SSR-generated html will be written to the page two times in this case
238+
retry_after: ->(request, response) do
239+
if (request.stream.instance_variable_get(:@react_on_rails_received_first_chunk))
240+
e = response.error
241+
raise ReactOnRailsPro::Error, "An error happened during server side render streaming of a component.\n" \
242+
"Original error:\n#{e}\n#{e.backtrace}"
243+
end
244+
245+
Rails.logger.info do
246+
"[ReactOnRailsPro] An error happneding while making a request to the Node Renderer.\n" \
247+
"Error: #{response.error}.\n" \
248+
"Retrying by HTTPX \"retries\" plugin..."
249+
end
250+
# The retry_after block expects to return a delay to wait before retrying the request
251+
# nil means no waiting delay
252+
nil
253+
end
254+
)
248255
.plugin(:stream)
249256
# See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options
250257
.with(

react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def initialize(component)
1010
# @param position [Symbol] The position of the chunk in the stream (:first, :middle, or :last)
1111
# The position parameter is used by actions that add content to the beginning or end of the stream
1212
@actions = [] # List to store all actions
13+
@rescue_blocks = []
1314
end
1415

1516
# Add a prepend action
@@ -39,27 +40,45 @@ def append
3940
self # Return self to allow chaining
4041
end
4142

43+
def rescue(&block)
44+
@rescue_blocks << block
45+
self # Return self to allow chaining
46+
end
47+
4248
def handle_chunk(chunk, position)
4349
@actions.reduce(chunk) do |acc, action|
4450
action.call(acc, position)
4551
end
4652
end
4753

48-
def each_chunk
49-
return enum_for(:each_chunk) unless block_given?
54+
def each_chunk(&block)
55+
return enum_for(:each_chunk) unless block
5056

5157
first_chunk = true
5258
@component.each_chunk do |chunk|
5359
position = first_chunk ? :first : :middle
5460
modified_chunk = handle_chunk(chunk, position)
55-
yield modified_chunk
61+
block.call(modified_chunk)
5662
first_chunk = false
5763
end
5864

5965
# The last chunk contains the append content after the transformation
6066
# All transformations are applied to the append content
6167
last_chunk = handle_chunk("", :last)
62-
yield last_chunk unless last_chunk.empty?
68+
block.call(last_chunk) unless last_chunk.empty?
69+
rescue StandardError => err
70+
current_error = err
71+
rescue_block_index = 0
72+
while current_error.present? && (rescue_block_index < @rescue_blocks.size)
73+
begin
74+
@rescue_blocks[rescue_block_index].call(current_error, &block)
75+
current_error = nil
76+
rescue StandardError => inner_error
77+
current_error = inner_error
78+
end
79+
rescue_block_index += 1
80+
end
81+
raise current_error if current_error.present?
6382
end
6483
end
6584

@@ -75,9 +94,6 @@ def each_chunk(&block)
7594

7695
send_bundle = false
7796
error_body = +""
78-
# Retry logic for streaming requests is handled here by starting fresh requests.
79-
# The HTTPx connection used for streaming has retries disabled (see Request#connection_without_retries)
80-
# to prevent body duplication when partial chunks are already sent to the client.
8197
loop do
8298
stream_response = @request_executor.call(send_bundle)
8399

@@ -89,6 +105,9 @@ def each_chunk(&block)
89105
break
90106
rescue HTTPX::HTTPError => e
91107
send_bundle = handle_http_error(e, error_body, send_bundle)
108+
rescue HTTPX::ReadTimeoutError => e
109+
raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
110+
"Original error:\n#{e}\n#{e.backtrace}"
92111
end
93112
end
94113

@@ -135,6 +154,7 @@ def loop_response_lines(response)
135154
line = "".b
136155

137156
response.each do |chunk|
157+
response.instance_variable_set(:@react_on_rails_received_first_chunk, true)
138158
line << chunk
139159

140160
while (idx = line.index("\n"))

react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/TestingStreamableComponent.jsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

react_on_rails_pro/spec/dummy/spec/helpers/react_on_rails_pro_helper_spec.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,6 @@ def response; end
330330
def mock_request_and_response(mock_chunks = chunks, count: 1)
331331
# Reset connection instance variables to ensure clean state for tests
332332
ReactOnRailsPro::Request.instance_variable_set(:@connection, nil)
333-
ReactOnRailsPro::Request.instance_variable_set(:@connection_without_retries, nil)
334333
original_httpx_plugin = HTTPX.method(:plugin)
335334
allow(HTTPX).to receive(:plugin) do |*args|
336335
original_httpx_plugin.call(:mock_stream).plugin(*args)

react_on_rails_pro/spec/react_on_rails_pro/request_spec.rb

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -194,32 +194,5 @@
194194
expect(mocked_block).not_to have_received(:call)
195195
end
196196
end
197-
198-
it "does not use HTTPx retries plugin for streaming requests to prevent body duplication" do
199-
# This test verifies the fix for https://github.com/shakacode/react_on_rails/issues/1895
200-
# When streaming requests encounter connection errors mid-transmission, HTTPx retries
201-
# would cause body duplication because partial chunks are already sent to the client.
202-
# The StreamRequest class handles retries properly by starting fresh requests.
203-
204-
# Reset connections to ensure we're using a fresh connection
205-
described_class.reset_connection
206-
207-
# Trigger a streaming request
208-
mock_streaming_response(render_full_url, 200) do |yielder|
209-
yielder.call("Test chunk\n")
210-
end
211-
212-
stream = described_class.render_code_as_stream("/render", "console.log('test');", is_rsc_payload: false)
213-
chunks = []
214-
stream.each_chunk { |chunk| chunks << chunk }
215-
216-
# Verify that the streaming request completed successfully
217-
expect(chunks).to eq(["Test chunk"])
218-
219-
# Verify that the connection_without_retries was created
220-
# by checking that a connection was created with retries disabled
221-
connection_without_retries = described_class.send(:connection_without_retries)
222-
expect(connection_without_retries).to be_a(HTTPX::Session)
223-
end
224197
end
225198
end

react_on_rails_pro/spec/react_on_rails_pro/stream_decorator_spec.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,93 @@
6262
expect(chunks.last).to end_with("-end")
6363
end
6464
end
65+
66+
describe "#rescue" do
67+
it "catches the error happens inside the component" do
68+
allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new "Fake Error")
69+
mocked_block = mock_block
70+
71+
stream_decorator.rescue(&mocked_block.block)
72+
chunks = []
73+
expect { stream_decorator.each_chunk { |chunk| chunks << chunk } }.not_to raise_error
74+
75+
expect(mocked_block).to have_received(:call) do |error|
76+
expect(error).to be_a(StandardError)
77+
expect(error.message).to eq("Fake Error")
78+
end
79+
expect(chunks).to eq([])
80+
end
81+
82+
it "catches the error happens inside subsequent component calls" do
83+
allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new "Fake Error")
84+
mocked_block = mock_block
85+
86+
stream_decorator.rescue(&mocked_block.block)
87+
chunks = []
88+
expect { stream_decorator.each_chunk { |chunk| chunks << chunk } }.not_to raise_error
89+
90+
expect(mocked_block).to have_received(:call) do |error|
91+
expect(chunks).to eq(["Chunk1"])
92+
expect(error).to be_a(ArgumentError)
93+
expect(error.message).to eq("Fake Error")
94+
end
95+
expect(chunks).to eq(["Chunk1"])
96+
end
97+
98+
it "can yield values to the stream" do
99+
allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(ArgumentError.new "Fake Error")
100+
mocked_block = mock_block
101+
102+
stream_decorator.rescue(&mocked_block.block)
103+
chunks = []
104+
expect { stream_decorator.each_chunk { |chunk| chunks << chunk } }.not_to raise_error
105+
106+
expect(mocked_block).to have_received(:call) do |error, &inner_block|
107+
expect(chunks).to eq(["Chunk1"])
108+
expect(error).to be_a(ArgumentError)
109+
expect(error.message).to eq("Fake Error")
110+
111+
inner_block.call "Chunk from rescue block"
112+
inner_block.call "Chunk2 from rescue block"
113+
end
114+
expect(chunks).to eq(["Chunk1", "Chunk from rescue block", "Chunk2 from rescue block"])
115+
end
116+
117+
it "can convert the error into another error" do
118+
allow(mock_component).to receive(:each_chunk).and_raise(StandardError.new "Fake Error")
119+
mocked_block = mock_block do |error|
120+
expect(error).to be_a(StandardError)
121+
expect(error.message).to eq("Fake Error")
122+
raise ArgumentError.new "Another Error"
123+
end
124+
125+
stream_decorator.rescue(&mocked_block.block)
126+
chunks = []
127+
expect { stream_decorator.each_chunk { |chunk| chunks << chunk } }.to raise_error(ArgumentError, "Another Error")
128+
expect(chunks).to eq([])
129+
end
130+
131+
it "chains multiple rescue blocks" do
132+
allow(mock_component).to receive(:each_chunk).and_yield("Chunk1").and_raise(StandardError.new "Fake Error")
133+
fist_rescue_block = mock_block do |error, &block|
134+
expect(error).to be_a(StandardError)
135+
expect(error.message).to eq("Fake Error")
136+
block.call "Chunk from first rescue block"
137+
raise ArgumentError.new "Another Error"
138+
end
139+
140+
second_rescue_block = mock_block do |error, &block|
141+
expect(error).to be_a(ArgumentError)
142+
expect(error.message).to eq("Another Error")
143+
block.call "Chunk from second rescue block"
144+
end
145+
146+
stream_decorator.rescue(&fist_rescue_block.block)
147+
stream_decorator.rescue(&second_rescue_block.block)
148+
chunks = []
149+
expect { stream_decorator.each_chunk { |chunk| chunks << chunk } }.not_to raise_error
150+
151+
expect(chunks).to eq(["Chunk1", "Chunk from first rescue block", "Chunk from second rescue block"])
152+
end
153+
end
65154
end

react_on_rails_pro/spec/react_on_rails_pro/support/mock_block_helper.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ module MockBlockHelper
99
# mocked_block = mock_block
1010
# testing_method_taking_block(&mocked_block.block)
1111
# expect(mocked_block).to have_received(:call).with(1, 2, 3)
12-
def mock_block(return_value: nil)
12+
def mock_block(&block)
1313
double("BlockMock").tap do |mock| # rubocop:disable RSpec/VerifiedDoubles
14-
allow(mock).to receive(:call) { return_value }
14+
allow(mock).to receive(:call) do |*args, &inner_block|
15+
block.call(*args, &inner_block) if block
16+
end
1517
def mock.block
1618
method(:call).to_proc
1719
end

0 commit comments

Comments
 (0)