Skip to content

Switch Pro renderer client to async-http#3279

Draft
justin808 wants to merge 35 commits into
mainfrom
feat/async-http-renderer-client-main
Draft

Switch Pro renderer client to async-http#3279
justin808 wants to merge 35 commits into
mainfrom
feat/async-http-renderer-client-main

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented May 13, 2026

Summary

  • Replaces the Pro node-renderer HTTP client dependency on httpx/http-2 with a small async-http adapter.
  • Preserves renderer request, streaming, retry, timeout, and error-body behavior through RendererHttpClient::Response.
  • Updates Pro tests and mocks to stop depending on HTTPX internals.
  • Bumps the Pro Ruby requirement to Ruby 3.3+ for the async-http stack.

Notes for Review

  • Renderer-origin requests force h2c via Async::HTTP::Protocol::HTTP2; dev-server asset GETs do not force HTTP/2 because those URLs can point at arbitrary HTTP servers.
  • The adapter scopes async-http clients to individual requests so clients never cross Async reactors. That means renderer_http_keep_alive_timeout is retained for configuration compatibility but is no longer applied by this adapter; assigning a non-nil value now warns.
  • Multipart bodies intentionally omit a manual content-length; async-http/protocol-http2 sets it, and duplicating it caused Fastify HTTP/2 stream resets during testing.

Validation

  • bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/request_spec.rb spec/react_on_rails_pro/stream_spec.rb spec/react_on_rails_pro/server_rendering_pool/node_rendering_pool_spec.rb - 44 examples, 0 failures.

  • Review follow-up: bundle exec rspec spec/react_on_rails_pro/stream_request_spec.rb spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/configuration_spec.rb in react_on_rails_pro - 63 examples, 0 failures.

  • Review follow-up: bundle exec rspec spec/react_on_rails_pro in react_on_rails_pro - 315 examples, 0 failures.

  • Pro dummy manual: bundle exec rake react_on_rails:generate_packs in react_on_rails_pro/spec/dummy.

  • Pro dummy manual: pnpm run build:test in react_on_rails_pro/spec/dummy - passed with existing DefinePlugin/import-meta warnings.

  • Pro dummy manual: bundle exec rspec spec/helpers/react_on_rails_pro_helper_spec.rb spec/requests/upload_asset_spec.rb in react_on_rails_pro/spec/dummy with the node renderer running - 57 examples, 0 failures.

  • OSS dummy manual: mise exec ruby@3.2.9 -- bundle exec rake react_on_rails:generate_packs in react_on_rails/spec/dummy.

  • OSS dummy manual: mise exec ruby@3.2.9 -- pnpm run build:test in react_on_rails/spec/dummy - passed with existing DefinePlugin warnings.

  • OSS dummy manual: mise exec ruby@3.2.9 -- bundle exec rspec spec/helpers/react_on_rails_helper_spec.rb spec/requests/server_render_check_spec.rb spec/packs_generator_spec.rb in react_on_rails/spec/dummy - 240 examples, 0 failures when run outside the sandbox for Capybara localhost binding.

  • pnpm run build.

  • pnpm run lint.

  • pnpm start format.listDifferent.

  • bundle exec rake rbs:validate.

  • git diff --check.

  • Touched-file bundle exec rubocop --cache-root /private/tmp/rubocop_cache_async_http_review --no-server --cache false ... - 7 files inspected, no offenses detected.

  • Pre-commit hook on commit 91531221d - trailing-newlines and changed-file RuboCop passed.

  • Pre-push hook after commit 91531221d - branch RuboCop on 13 changed Ruby files and online markdown-link checks passed.

  • Heartbeat follow-up: bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/request_spec.rb spec/react_on_rails_pro/stream_request_spec.rb spec/react_on_rails_pro/configuration_spec.rb in react_on_rails_pro - 91 examples, 0 failures.

  • Heartbeat follow-up: bundle exec rspec spec/react_on_rails_pro in react_on_rails_pro - 322 examples, 0 failures.

  • Heartbeat follow-up: focused bundle exec rubocop --cache-root /private/tmp/rubocop_cache_async_http_heartbeat --no-server --cache false ... - 8 files inspected, no offenses detected.

  • Heartbeat follow-up: git diff --check.

  • Pre-commit hook on commit c22faaaee - trailing-newlines, autofix, and changed-file RuboCop passed.

  • Pre-push hook after commit c22faaaee - branch RuboCop on 13 changed Ruby files and online markdown-link checks passed.

  • Review follow-up commit 4425e64f0: bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb in react_on_rails_pro - 17 examples, 0 failures.

  • Review follow-up commit 4425e64f0: bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/request_spec.rb spec/react_on_rails_pro/stream_request_spec.rb spec/react_on_rails_pro/configuration_spec.rb in react_on_rails_pro - 94 examples, 0 failures.

  • Review follow-up commit 4425e64f0: bundle exec rspec spec/react_on_rails_pro in react_on_rails_pro - 325 examples, 0 failures.

  • Review follow-up commit 4425e64f0: focused bundle exec rubocop --cache-root /private/tmp/rubocop_cache_async_http_post --no-server --cache false ... - 2 files inspected, no offenses detected.

  • Review follow-up commit 4425e64f0: git diff --check.

  • Pre-commit hook on commit 4425e64f0 - trailing-newlines, autofix, and changed-file RuboCop passed.

  • Pre-push hook after commit 4425e64f0 - branch RuboCop on 13 changed Ruby files and online markdown-link checks passed.

  • Review follow-up commit 960e91b14: bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/stream_request_spec.rb in react_on_rails_pro - 21 examples, 0 failures.

  • Review follow-up commit 960e91b14: bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb spec/react_on_rails_pro/request_spec.rb spec/react_on_rails_pro/stream_request_spec.rb spec/react_on_rails_pro/configuration_spec.rb in react_on_rails_pro - 96 examples, 0 failures.

  • Review follow-up commit 960e91b14: bundle exec rspec spec/react_on_rails_pro in react_on_rails_pro - 327 examples, 0 failures.

  • Review follow-up commit 960e91b14: focused bundle exec rubocop --cache-root /private/tmp/rubocop_cache_async_http_comments --no-server --cache false ... - 4 files inspected, no offenses detected.

  • Review follow-up commit 960e91b14: pnpm start format.listDifferent.

  • Review follow-up commit 960e91b14: git diff --check.

  • Pre-commit hook on commit 960e91b14 - trailing-newlines, markdown-links, Prettier, and changed-file RuboCop passed.

  • Pre-push hook after commit 960e91b14 - branch RuboCop on 14 changed Ruby files and online markdown-link checks passed.

  • Review follow-up commit c03127d7f: bundle exec rake rbs:validate - passed.

  • Pre-commit hook on commit c03127d7f - trailing-newlines passed.

  • Pre-push hook after commit c03127d7f - branch RuboCop on 14 changed Ruby files and online markdown-link checks passed.

  • Follow-up commit 1dd09644e: bundle exec rspec spec/react_on_rails_pro in react_on_rails_pro - 329 examples, 0 failures.

  • Follow-up commit 1dd09644e: Pro dummy node renderer manual smoke, bundle exec rspec spec/helpers/react_on_rails_pro_helper_spec.rb spec/requests/upload_asset_spec.rb in react_on_rails_pro/spec/dummy - 57 examples, 0 failures.

  • Follow-up commit 1dd09644e: repeat bundle exec rspec spec/requests/upload_asset_spec.rb twice against the same live node renderer - 3 examples, 0 failures each run; verifies renderer cache cleanup for uploaded assets.

  • Follow-up commit 1dd09644e: OSS dummy manual smoke, bundle exec rspec spec/helpers/react_on_rails_helper_spec.rb spec/requests/server_render_check_spec.rb in react_on_rails/spec/dummy - 118 examples, 0 failures when run outside the sandbox for Capybara localhost binding.

  • Follow-up commit 1dd09644e: bundle exec rake rbs:validate, pnpm start format.listDifferent, pnpm run build, touched-file RuboCop, git diff --check, pre-commit, and pre-push hooks passed.

  • Local transport smoke benchmark against the Pro dummy node renderer /asset-exists: sequential 100 requests avg 1.68 ms, p50 1.35 ms, p95 4.87 ms, max 5.07 ms; concurrent 200 requests across 10 threads, 1461 req/s, avg 6.73 ms, p95 12.74 ms.

Full bundle exec rubocop --cache-root /private/tmp/rubocop_cache_async_http_review --no-server --cache false still fails on the existing repo baseline: 550 offenses under generated/support files such as react_on_rails_pro/spec/dummy/bin/*, react_on_rails_pro/spec/dummy/db/schema.rb, and script/convert.


Note

High Risk
Replaces the Pro Rails-to-node-renderer transport layer and changes connection/pooling/timeout semantics for SSR and streaming, plus bumps the minimum Ruby version to 3.3—both can impact production rendering behavior and performance.

Overview
React on Rails Pro now uses async-http instead of httpx for all Rails→Node Renderer requests (render, streaming render, asset upload, and dev-server asset fetch), implemented via a new RendererHttpClient with explicit TimeoutError/ConnectionError/HTTPError handling and multipart/form/JSON body builders.

Configuration semantics shift: renderer_http_pool_size becomes a per-request HTTP/2 stream limit (with warnings when set), renderer_http_pool_timeout is treated as a TCP connect timeout, and renderer_http_keep_alive_timeout is retained but effectively deprecated (warns when set) because clients are now request-scoped (no cross-request connection reuse yet).

Updates streaming behavior and tests/mocks to be HTTPX-independent, including a fix for duplicated newline-prefixed chunks and new first-chunk latency warning logic; also updates docs/changelog and bumps Pro’s required Ruby to 3.3+ with new gem dependencies (async-http, io-endpoint).

Reviewed by Cursor Bugbot for commit fc31472. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3120e7c6-b114-4833-9caf-e421784bbd88

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/async-http-renderer-client-main

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/request.rb Fixed
Comment thread react_on_rails_pro/lib/react_on_rails_pro/request.rb Fixed
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 13, 2026

Code Review: Switch Pro renderer client to async-http

Overview

This PR replaces the httpx/http-2 Node renderer HTTP stack with a bespoke async-http adapter (RendererHttpClient). The approach is sound — eliminating the HTTPX plugin system simplifies the test infrastructure considerably, and the custom Response abstraction maps cleanly onto the existing streaming/error-handling contract. A few things to address before merging:


Bugs / Correctness

Errno::ECONNREFUSED is not rescued (medium risk)

execute_request maps low-level errors to ConnectionError, but the rescue list omits Errno::ECONNREFUSED:

rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Protocol::HTTP::RefusedError => e
  raise ConnectionError, e.message

Protocol::HTTP::RefusedError is a protocol-level refusal, not a TCP connection refusal. When the Node renderer process is not running, async-http surfaces a raw Errno::ECONNREFUSED, which will propagate unrescued and produce a confusing stack trace. Add Errno::ECONNREFUSED to the list.


Dead code: @react_on_rails_received_first_chunk ivar set on response (minor)

stream_request.rb still does response.instance_variable_set(:@react_on_rails_received_first_chunk, true) inside loop_response_lines. This ivar was read by the old HTTPX retry_after lambda to abort retries mid-stream. There is no reader for it anywhere in the new code, and the new adapter has no retry-after callback hook. It should be removed to avoid confusion.


Style / Maintainability

Redundant exception-chaining idiom in get_form_body_for_file

The pattern inside rescue ReactOnRailsPro::RendererHttpClient::Error => e uses an inner begin; raise e; rescue StandardError; raise ReactOnRailsPro::Error, "..."; end to set $!.cause. But you are already inside a rescue block, so $! is already e — Ruby automatically sets cause when you raise from within a rescue. The inner wrapper is unnecessary.


attr_accessor :status exposes unnecessary mutation

Response#status is written only by execute_request (internal), but attr_accessor makes it publicly writable. Consider attr_reader :status with a private writer or direct ivar assignment to tighten the interface.


Missing

No RBS signatures for RendererHttpClient

Other public classes in the gem have .rbs files under sig/react_on_rails_pro/. RendererHttpClient and its nested Response, HTTPError, etc. have none. rbs:validate passes because it only validates existing files, not missing ones. A stub covering the public interface would be consistent with the project's type-checking posture.


Acknowledged trade-offs (no action required)

  • renderer_http_keep_alive_timeout accepted but not applied: documented clearly in the config comment and PR description. Reasonable given the per-request client design.
  • pool_size maps to limit: on a per-request client: semantically different from a real connection pool. Worth monitoring in production to confirm limit: 1 (for asset GETs) is never a bottleneck.
  • Ruby 3.3+ requirement bump: a breaking change for some users, but async-http 0.95 requires it transitively, so it is unavoidable.

end
rescue Async::TimeoutError, IO::TimeoutError => e
raise TimeoutError, e.message
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Protocol::HTTP::RefusedError => e
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Errno::ECONNREFUSED is missing here. Protocol::HTTP::RefusedError is a protocol-level concept; when the Node renderer process is simply not running, async-http surfaces a raw Errno::ECONNREFUSED at the socket level. Without catching it, a downed renderer will produce an unrescued Errno::ECONNREFUSED instead of a clean ConnectionError.

Suggested change
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Protocol::HTTP::RefusedError => e
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE,
Errno::ECONNREFUSED, Protocol::HTTP::RefusedError => e

# This method is considered as an override of response.each_line.
# It yields the final partial line even when the renderer returns an error status.
def loop_response_lines(response)
return enum_for(__method__, response) unless block_given?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few lines below here (line 165): response.instance_variable_set(:@react_on_rails_received_first_chunk, true) is dead code. This ivar was read by the old HTTPX retry_after lambda in create_connection to abort retries once streaming had begun. That callback no longer exists in the new async-http adapter, and nothing in the current codebase reads this ivar. It should be removed.

Comment on lines +278 to +282
rescue ReactOnRailsPro::RendererHttpClient::Error => e
begin
raise e
rescue StandardError
raise ReactOnRailsPro::Error, "Failed to fetch dev-server asset from #{path}: #{e}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The inner begin; raise e; rescue StandardError; … end wrapper is redundant. You are already inside rescue … => e, so $! is e and Ruby will automatically set cause on any new exception raised from this rescue block. This simplifies to:

Suggested change
rescue ReactOnRailsPro::RendererHttpClient::Error => e
begin
raise e
rescue StandardError
raise ReactOnRailsPro::Error, "Failed to fetch dev-server asset from #{path}: #{e}"
rescue ReactOnRailsPro::RendererHttpClient::Error => e
raise ReactOnRailsPro::Error, "Failed to fetch dev-server asset from #{path}: #{e}"

end

class Response
attr_accessor :status
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

status only needs to be writable from execute_request inside the same class. Exposing the writer publicly via attr_accessor means external callers could overwrite the status after the response is received. Consider attr_reader :status and assigning via @status = inside execute_request to keep mutation internal.

end
rescue Async::TimeoutError, IO::TimeoutError => e
raise TimeoutError, e.message
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Protocol::HTTP::RefusedError => e
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing Errno::ECONNREFUSED — when the node renderer process is not running, the OS raises Errno::ECONNREFUSED. That error is not in this rescue list, so it will bubble up as an unhandled exception instead of a tidy ConnectionError.

Protocol::HTTP::RefusedError is a protocol-level concept (e.g., HTTP/2 RST_STREAM), not the TCP-layer refused-connection errno, so it doesn't cover this case.

Suggested change
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Protocol::HTTP::RefusedError => e
rescue SocketError, IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED, Protocol::HTTP::RefusedError => e

def stream_body(raw_response, yielder)
raw_response.body&.each { |chunk| yielder.call(chunk) }
ensure
raw_response.body&.close if raw_response&.body
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Double raw_response.body call in the ensure clause. raw_response.body is evaluated twice — once for the nil guard and once for .close. If calling body has side effects (e.g., buffering the body in async-http), this could be surprising. Simplify to a single call using safe navigation:

Suggested change
raw_response.body&.close if raw_response&.body
raw_response.body&.close


def with_client(&block)
endpoint = endpoint_for(@origin)
Async::HTTP::Client.open(endpoint, protocol: endpoint.protocol, retries: 1, limit: @pool_size) do |client|
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Transport-level retries: 1 stacks on top of application-level retries. Async::HTTP::Client will silently retry once at the transport layer on connection reset/closed, independently of ReactOnRailsPro.configuration.renderer_request_retry_limit. A non-streaming render request that hits a transient error could therefore be attempted up to (1 + renderer_request_retry_limit) * 2 times total, which may surprise operators watching logs.

Consider setting retries: 0 here and letting the application-level retry loop in perform_request be the single authority, or document the double-retry explicitly in the configuration docs.

# @param value [Numeric, nil] A positive number or nil (to use the HTTPX default)
# @param value [Numeric, nil] A positive number or nil
# @raise [ReactOnRailsPro::Error] if value is not a positive number or nil
def renderer_http_keep_alive_timeout=(value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent no-op config — consider emitting a deprecation warning. The setter accepts and validates the value but never applies it. Users who have config.renderer_http_keep_alive_timeout = 25 in their initializer will get no feedback that it now does nothing.

A warn on assignment (like the project does for RENDERER_BUNDLE_PATH) would surface the behavior change during app boot:

def renderer_http_keep_alive_timeout=(value)
  unless value.nil? || (value.is_a?(Numeric) && value.positive? && value.finite?)
    raise ReactOnRailsPro::Error,
          "config.renderer_http_keep_alive_timeout must be a finite positive number or nil"
  end
  warn "[ReactOnRailsPro] config.renderer_http_keep_alive_timeout is deprecated and has no effect " \
       "with the async-http adapter (clients are scoped per-request)." unless value.nil?
  @renderer_http_keep_alive_timeout = value
end

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 13, 2026

Code Review

This is a well-structured transport swap. The new RendererHttpClient class has a clear interface, the mock infrastructure is much simpler than the old HTTPX plugin, and the Response executor pattern cleanly separates streaming from buffered semantics. A few issues worth addressing before merge:


Bugs / Correctness

1. Errno::ECONNREFUSED not rescued → unhandled exception when renderer is down
(inline comment on renderer_http_client.rb line 242)
When the node renderer process is not running, the OS raises Errno::ECONNREFUSED. That isn't in the rescue list, so it surfaces as an unhandled Errno::ECONNREFUSED instead of being wrapped in ConnectionError the way every other network failure is. Protocol::HTTP::RefusedError is an HTTP/2 stream-level concept and does not cover the TCP-layer errno.

2. Stale HTTPX-era instance variable in stream_request.rb:165

response.instance_variable_set(:@react_on_rails_received_first_chunk, true)

This was read by the retry_after lambda inside the old HTTPX create_connection factory to prevent retrying after the first chunk had been flushed to the browser. That lambda was deleted in this PR. Nothing in the new codebase reads this ivar — it is dead code and should be removed to avoid confusion.


Behavior Changes Worth Discussing

3. Transport-level retries: 1 stacks on application-level retries
(inline comment on renderer_http_client.rb line 256)
Async::HTTP::Client.open(…, retries: 1) silently retries once at the transport layer, independently of renderer_request_retry_limit. A non-streaming request that hits a transient reset could therefore be retried more times than the configuration implies. Consider retries: 0 here and letting perform_request's loop be the single retry authority, or document the interaction explicitly.

4. No connection reuse across requests
A new Async::HTTP::Client is opened for every single request (the close method is a no-op). For deployments that make many rapid SSR calls, this means a new TCP+TLS handshake per render. The PR notes acknowledge this, but there is no measurement or benchmark showing the impact is acceptable. At minimum, a follow-up ticket to revisit connection reuse would be reassuring.

5. renderer_http_keep_alive_timeout silently ignored
(inline comment on configuration.rb line 103)
The setter validates the value and stores it but never applies it. Apps that already set this config will get no feedback. A warn at assignment time (like the project already does for deprecated config like RENDERER_BUNDLE_PATH) would surface this change at boot.


Minor / Style

6. Double raw_response.body call in stream_body ensure
(inline comment on renderer_http_client.rb line 273)

raw_response.body&.close if raw_response&.body   # body evaluated twice

Should be raw_response.body&.close.

7. No RBS type signatures for RendererHttpClient
Other Pro classes (AsyncValue, Cache, LicenseValidator, etc.) have .rbs files under sig/. The new RendererHttpClient class and its nested Response, Error, TimeoutError, ConnectionError, HTTPError types have none. The PR notes that rbs:validate passes, but only because there is nothing to validate yet.


Summary

The two actionable bugs before merge are Errno::ECONNREFUSED (#1) and the orphaned @react_on_rails_received_first_chunk write (#2). The rest are quality-of-life improvements or documentation gaps that could land in a follow-up.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb
Comment thread react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
Comment on lines +251 to +256
def run_with_timeout(&block)
if (task = Async::Task.current?)
task.with_timeout(@read_timeout, &block)
else
Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Connect timeout vs read timeout budget

@connect_timeout is passed to endpoint_for as the timeout: option, but the with_timeout wrapper here uses @read_timeout to cap the entire operation (TCP connect + TLS handshake + HTTP/2 connection + read). If a TCP connect is slow, it consumes from the read timeout budget rather than the connect timeout budget.

In practice this means a connect_timeout=5 / read_timeout=30 config doesn't behave as users would expect — the connect phase isn't independently bounded by 5s; it's still bounded by 30s total. Worth a brief note in the deprecation/docs for renderer_http_pool_timeout, especially since the old HTTPX client did apply them independently.

client.get(path, headers: Protocol::HTTP::Headers[headers])
end

response.__send__(:assign_status, raw_response.status)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

__send__ to call a private method across object boundary

Calling response.__send__(:assign_status, ...) from inside execute_request crosses the private-method contract. The executor block already creates a tight coupling between RendererHttpClient and Response; making assign_status protected (or using a yielded callback) would express that coupling without bypassing Ruby's visibility rules.

Minimal alternative — make it a protected setter instead:

protected

def assign_status(status)
  @status = status
end

Then the call site can use response.send(:assign_status, ...) with the same effect, but it's a clear "friend class" pattern rather than __send__.

Comment on lines +160 to +168
body << %(Content-Disposition: form-data; name="#{name}"\r\n)
body << "\r\n"
body << value.to_s
body << "\r\n"
end

def append_file_part(body, boundary, name, value)
body << "--#{boundary}\r\n"
body << %(Content-Disposition: form-data; name="#{name}"; filename="#{value.fetch(:filename)}"\r\n)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Content-Disposition header values are interpolated without escaping

name (line 160) and filename (line 168) are interpolated directly into the Content-Disposition header. A double-quote " or embedded \r\n in either value would corrupt the MIME header structure — potentially allowing header injection.

Since these fields come from internal gem code (form keys like "bundle_server_bundle.js", "password", etc.) the real-world risk is low. But if filename ever incorporates a user-supplied or file-system-derived path component, the exposure grows. A defensive guard:

def sanitize_header_param(value)
  value.to_s.gsub(/"/, '\\"').gsub(/[\r\n]/, "")
end

and usage like name="#{sanitize_header_param(name)}" would eliminate the class of issue entirely.

Comment on lines +259 to +265
def with_client(&block)
endpoint = endpoint_for(@origin)
Async::HTTP::Client.open(endpoint, protocol: endpoint.protocol, retries: 0, limit: @pool_size) do |client|
# rubocop:disable Performance/RedundantBlockCall
block.call(client)
# rubocop:enable Performance/RedundantBlockCall
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pool_size / limit: semantic shift

In the HTTPX world renderer_http_pool_size controlled the number of persistent connections kept in a pool across requests. Here, limit: is passed to Async::HTTP::Client.open, which creates a per-request client whose pool only lives for the duration of a single post/get call. For H2C (forced for the renderer), limit: effectively caps concurrent streams on that single connection, not a long-lived connection pool.

The PR notes say keep_alive_timeout is silently no-op'ed with a deprecation warning. pool_size has a similarly changed meaning and might be worth a similar deprecation note (or at least a config-level comment), so users don't spend time tuning a value that no longer controls what the docs said it did.

Comment on lines 127 to 133
def response_has_error_status?(response)
return true if response.is_a?(HTTPX::ErrorResponse)
return response.error? if response.respond_to?(:error?)

response.status >= 400
rescue NoMethodError
# HTTPX::StreamResponse can fail to delegate #status for non-streaming errors.
true
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

rescue NoMethodError is now a dead branch

RendererHttpClient::Response always defines error?, so response.respond_to?(:error?) will always be true and we'll always return early. The rescue NoMethodError fallback (and the response.status >= 400 line) can no longer trigger for the current adapter.

This is fine to keep as defensive dead code, but removing the comment that described the HTTPX-specific reason would be a good idea — the original comment was already deleted in this PR, so the only thing left is the rescue clause with no explanation. A brief comment like # guard for non-Response objects returned by future adapters would clarify intent.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 14, 2026

Code Review — PR #3279: Switch Pro renderer client to async-http

Overview

A well-scoped transport replacement. The new RendererHttpClient wrapper is clean, the HTTPX-internal coupling is fully removed from tests, and the streaming/retry/error-body behavior is preserved. The deprecation of renderer_http_keep_alive_timeout (with the suppress-flag trick to avoid warning on initializer defaults) is a nice touch. I left inline comments on the specific items below; none are blockers, but a few are worth addressing before merge.


Correctness

perform_request — potential nil comparison (pre-existing, not introduced here)

request.rb:122 currently reads:

if request_time > warn_timeout

If renderer_http_pool_warn_timeout is nil, this raises NoMethodError. The old HTTPX code had if warn_timeout && request_time > warn_timeout. This appears to have existed before this PR, but since this method was touched in the diff, it would be low-cost to add the guard now.

response = nil initialization in perform_request

Good defensive addition — prevents a NameError if the loop exits without setting response. In practice it can't happen (all exit paths either set response or raise), but this is clearer than relying on that reasoning.

Streaming retry protection removed — intentional and correct

The old HTTPX client used an @react_on_rails_received_first_chunk flag to prevent connection-level retries after chunks were sent. That flag is gone because the new adapter has no connection-level retries; it creates fresh per-request clients. The existing StreamRequest#each_chunk still only retries on HTTPError (bundle-required), and immediately re-raises timeout/connection errors. The protection is now architectural rather than flag-based — this is correct.


Design / Architecture

pool_size semantic shift (see inline)
The renderer_http_pool_size config used to control the number of persistent connections in an HTTPX pool. With per-request clients, it now maps to limit: in Async::HTTP::Client.open, which controls concurrent streams per connection per request. Users tuning this value will observe no effect for typical workloads. Consider a deprecation warning similar to keep_alive_timeout, or at minimum a config-level comment.

run_with_timeout applies read_timeout to the entire connect+read cycle (see inline)
connect_timeout is passed to the endpoint but the with_timeout wrapper uses read_timeout for everything. The two timeouts are no longer independently enforced.

__send__(:assign_status, ...) (see inline)
Minor encapsulation concern. protected would be cleaner than private + __send__.

Content-Disposition escaping (see inline)
Low risk given internal inputs, but a one-line sanitize would eliminate the class of issue.


Test Quality

Much improved. Removing the custom HTTPX mock plugin (~154 lines) and replacing it with a clean MockClient/MockStream pair is a clear win. The new renderer_http_client_spec.rb tests the adapter directly against real Response objects rather than HTTPX internals. The deleted stream_spec.rb sections that documented HTTPX "weird behavior" (body always empty, wrong == operator) are correctly removed since those were HTTPX-specific quirks.


Breaking Changes

  • Ruby 3.3+ requirement is clearly documented in CHANGELOG, README, and gemspec. ✓
  • renderer_http_keep_alive_timeout deprecated with a runtime warning. ✓
  • The async-http ~> 0.95 pin is reasonable; the ~> constraint allows patch updates but not minor bumps, which is appropriate for a young gem in active development.

Summary

The core logic is sound and the test coverage is solid. The five inline comments are the main items to evaluate; the connect/read timeout conflation and pool_size behavioral change are the ones most likely to surprise users in production.

Comment thread react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb
end

def build_multipart_body(form, boundary: SecureRandom.hex(24))
body = +""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Potential Encoding::CompatibilityError with binary file bodies

body = +"" creates a mutable UTF-8 string. Pathname#binread (line 192) returns ASCII-8BIT. Concatenating them with << raises Encoding::CompatibilityError if the file content contains bytes outside the ASCII range — which can happen with minified JS containing embedded binary assets or certain Unicode characters beyond the BMP.

Suggested change
body = +""
body = +"".force_encoding(Encoding::BINARY)

And in append_scalar_part / append_file_part, ensure the boundary/header strings are ASCII-compatible (they already are since boundary is hex and headers are ASCII), so forcing the body to BINARY here is safe and lets binary file content concatenate without encoding issues.

filename = sanitize_header_param(value.fetch(:filename))
body << "--#{boundary}\r\n"
body << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"\r\n)
body << "Content-Type: #{value.fetch(:content_type)}\r\n"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

content_type not sanitized against CRLF injection

name and filename are sanitized via sanitize_header_param, but value.fetch(:content_type) is inserted into the header line directly. A content_type value containing \r\n would inject an extra header field. Since callers are internal, the practical risk is low, but it's inconsistent with the sanitization applied to the other parameters.

Suggested change
body << "Content-Type: #{value.fetch(:content_type)}\r\n"
body << "Content-Type: #{sanitize_header_param(value.fetch(:content_type))}\r\n"

end
end

def close
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No-op close breaks the expectation that Request#reset_connection cleans up resources

Request holds a @connection class-level instance variable and calls @connection.close in reset_connection. With HTTPX this closed the persistent socket pool. With a no-op close, calling reset_connection silently does nothing at the transport level — which is correct for this per-request design, but a future developer who doesn't read both files will be confused.

Consider removing close entirely and letting Request duck-type against respond_to?(:close), or raising NotImplementedError with a clear explanation. At minimum the comment here should say the method must stay for Request compatibility, not just explain why it's a no-op.

raise ConnectionError, e.message
end

def run_with_timeout(&block)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@read_timeout bounds the entire operation, including TCP connect

@connect_timeout is passed as the endpoint-level TCP connect timeout (line 282), but @read_timeout (which is ssr_timeout) wraps everything: connect, TLS handshake, request write, and response body streaming. That's the right behavior — SSR rendering must finish within ssr_timeout regardless of where time is spent — but the parameter name read_timeout is misleading here.

A comment like:

# @read_timeout is the ssr_timeout: it bounds the total wall-clock time
# for this request including TCP connect, so a slow connect still counts
# against the SSR deadline.

would prevent future confusion with the separate @connect_timeout.

Async::HTTP::Endpoint.parse(origin, **options)
end

def stream_body(raw_response, yielder)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

raw_response.body accessed twice in stream_body

raw_response.body is called on line 289 (to iterate) and again on line 291 (to close). For most async-http response bodies this is fine, but if the body object is a one-shot object (e.g., a Protocol::HTTP2::Body::Reader) the second call to raw_response.body may return nil after the first each has consumed and released it — making the close a no-op rather than an explicit drain.

It's safer to capture the body once:

Suggested change
def stream_body(raw_response, yielder)
def stream_body(raw_response, yielder)
body = raw_response&.body
body&.each { |chunk| yielder.call(chunk) }
ensure
body&.close
end


it "does not expose a public chunk writer" do
response = described_class.new(status: 200)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SocketError and IOError are in CONNECTION_ERRORS but not covered by this parametrized list. Both are plausible in real-world DNS/socket failure scenarios. Consider adding them:

Suggested change
[
SocketError,
IOError,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
Errno::EHOSTUNREACH,
Errno::ENETUNREACH,
Errno::EPIPE,
Errno::ETIMEDOUT,
Protocol::HTTP::RefusedError,
[Protocol::HTTP2::StreamError, "stream reset"]
].each do |error_class, *args|


raise Error, "io-endpoint #{IO::Endpoint::VERSION} is unsupported; async-http renderer client " \
"requires #{IO_ENDPOINT_REQUIREMENT} because ConnectTimeoutWrapper relies on " \
"IO::Endpoint::Wrapper internals."
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The error message could be more actionable for gem users. When this fires, they'll see a bare load error and need to figure out what to do. Suggesting a fix in the message costs one line:

Suggested change
"IO::Endpoint::Wrapper internals."
raise Error, "io-endpoint #{IO::Endpoint::VERSION} is unsupported; async-http renderer client " \
"requires #{IO_ENDPOINT_REQUIREMENT} because ConnectTimeoutWrapper relies on " \
"IO::Endpoint::Wrapper internals. " \
"Pin `gem 'io-endpoint', '#{IO_ENDPOINT_REQUIREMENT}''` in your Gemfile or run `bundle update io-endpoint`."

# The mock pre-sets status above; pass the request so specs can assert on the captured payload.
block.call(->(value) { yielder.call(value) }, request)
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Mock pre-sets streaming status unlike real lazy behavior

Low Severity

The MockClient#build_response for streaming requests creates a Response with status: pre-set, while the real RendererHttpClient#post(stream: true) returns a Response with status: nil until the executor runs. This means perform_request's STATUS_INCOMPATIBLE guard (response.status && response.status == STATUS_INCOMPATIBLE) fires in tests but is always skipped in production for streaming, exercising a different code path than what runs live.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 76f1a1b. Configure here.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 15, 2026

PR 3279 — Switch Pro renderer client to async-http

Overview

A significant transport-layer change that replaces the HTTPX persistent-connection pool with per-request async-http clients. The RendererHttpClient abstraction encapsulates HTTP mechanics (multipart encoding, retry semantics, error mapping) behind a well-typed Response, and HTTPX plugin/mock internals have been removed from tests. The streaming newline-buffer duplication fix is a genuine correctness improvement.


Strengths

  • Clean abstraction. RendererHttpClient with a typed error hierarchy, a Response class with deterministic replay semantics, and self-contained multipart encoding with CRLF header-injection protection is a much better interface than embedding logic in HTTPX plugin hooks.
  • validate_io_endpoint_version! fail-fast. Crashing at load time beats silent runtime misbehaviour if the wrapper contract breaks.
  • Thorough test coverage. Connection error mapping, timeout wrapping, multipart encoding edge cases (binary, UTF-8, CRLF injection), and streaming reassembly all have explicit tests.
  • Correct byteslice fix. The old byteslice(0..idx-1) with idx == 0 (a \n-leading chunk) returned the full buffer, duplicating accumulated data. byteslice(0, idx) is correct and the new test confirms it.
  • Deprecation warning suppression on init. renderer_http_pool_size and renderer_http_keep_alive_timeout warnings are correctly gated so existing configs don't spam logs on first boot.

Concerns

1. Per-request TCP + HTTP/2 reconnect overhead (architectural)

Every renderer call now performs a full TCP connect + HTTP/2 SETTINGS exchange. The loopback benchmark numbers (1.68 ms avg) will be optimistic for any production topology where Rails and the renderer run on separate hosts — a single HTTP/2 handshake to a remote host typically costs 5–30 ms, adding directly to every SSR latency budget. Issue #3283 tracks persistent connection reuse, but that work should land before this is recommended for non-loopback deployments. The CHANGELOG note is accurate, which is good — this just needs a concrete follow-up milestone.

2. io-endpoint tight pin creates a future breakage vector

The ~> 0.17.0 pin is intentional (ConnectTimeoutWrapper relies on IO::Endpoint::Wrapper internals), but async-http itself only declares io-endpoint (~> 0.14). If async-http cuts a release whose io-endpoint lower-bound moves past 0.17, a bundle update async-http in a downstream app will pull in io-endpoint 0.18+, hit validate_io_endpoint_version!, and crash at boot. The upgrade guide should explicitly warn: do not run bundle update async-http without checking io-endpoint stays in the 0.17.x range.

3. Silent deadlock risk for Falcon/async-Rails users

The run_with_timeout fallback to Sync { … } is well-commented, but the failure mode (deadlock) is silent and hard to diagnose. The upgrade guide covers it, but operators who don't read changelogs closely may miss it. Consider raising a loud ReactOnRailsPro::Error if called from inside an existing Async reactor context rather than creating a nested reactor that may deadlock silently.

4. renderer_http_pool_size = nil silently falls back to the constant default

Setting nil does not warn (intended), but pool_limit then returns DEFAULT_RENDERER_HTTP_POOL_SIZE (10). A user who sets nil thinking it means "no stream limit" will silently get 10. A one-line note in the config comment or the pool_limit method would prevent confusion.


Minor items

See inline comments below.

end

def connect(remote_address, **options)
socket = super(remote_address, **options.merge(connect_timeout ? { timeout: connect_timeout } : {}))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor nit: the ternary merge can be simplified to { timeout: connect_timeout }.compact, which avoids the conditional entirely and reads more clearly when connect_timeout is nil:

Suggested change
socket = super(remote_address, **options.merge(connect_timeout ? { timeout: connect_timeout } : {}))
socket = super(remote_address, **options.merge({ timeout: connect_timeout }.compact))


def error?
# nil status means a lazy streaming executor has not run yet, so it is not-yet-an-error.
!status.nil? && status >= 400
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

error? returning false for a nil status is intentional (lazy executor hasn't run yet), but it makes error? a three-state concept in practice: false can mean "definitely not an error" or "status unknown". A caller who reads response.error? before triggering the executor will silently get false regardless of the eventual outcome.

If there is any path where external code might call error? before each/body has been driven, consider returning nil (or raising) for the unresolved case instead so callers can't mistake "not yet known" for "success". The current design is safe in the existing call chain, but worth documenting explicitly here so future consumers of Response understand the invariant.

end
end

validate_io_endpoint_version!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class-body call runs at require time, which is the right place for a dependency version guard. One thing to note: the error message tells users to "run bundle update io-endpoint", but if async-http is already pinned to a version whose own declared io-endpoint constraint conflicts with ~> 0.17.0, running bundle update will fail with a resolver conflict rather than silently rolling io-endpoint back. The message could be clearer that a Gemfile pin like gem "io-endpoint", "~> 0.17.0" may be required, not just a bundle update.

Comment on lines +374 to +382
def pool_limit
return @pool_size unless @pool_size.nil?

if defined?(ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE)
ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE
else
10
end
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The defined? guard makes RendererHttpClient loadable in isolation (e.g., in specs that require only this file), which is a valid reason to have it. However, the hardcoded 10 fallback is dead code in any realistic production or test scenario — Configuration is always loaded when the gem is in use. If you want to keep the guard for standalone-require safety, at least document why the magic 10 exists:

def pool_limit
  return @pool_size unless @pool_size.nil?

  # Fall back to the configuration default. The defined? guard lets this file be
  # required in isolation (e.g., unit specs) without pulling in the full gem.
  if defined?(ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE)
    ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE
  else
    10 # matches Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE
  end
end

Alternatively, move the constant out of Configuration and into a shared module so RendererHttpClient can reference it without a guard.


while (idx = line.index("\n"))
yield line.byteslice(0..idx - 1)
yield line.byteslice(0, idx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the fix for the data-duplication bug. Worth calling out explicitly for future readers: the old form was byteslice(0..idx - 1), which is an inclusive range. When idx == 0 (the chunk starts with \n), 0..-1 returns the entire line buffer, causing the buffered content to be yielded a second time. byteslice(0, idx) with a length argument returns "" when idx == 0, which is correct — an empty line is yielded, and the buffer advances past the newline.

Comment on lines +336 to +350
def run_with_timeout(&block)
# @read_timeout carries ssr_timeout and bounds the whole renderer request,
# including TCP connect, request write, and response body streaming.
# Async treats nil as no timeout; configuration keeps that legacy escape hatch explicit.
if (task = Async::Task.current?)
task.with_timeout(@read_timeout, &block)
else
# Rails calls this from a synchronous request thread; Sync blocks that thread
# while async-http drives the renderer exchange inside a temporary reactor.
# If an async server reaches this fallback from inside an existing reactor without
# Async::Task.current?, Sync may create a nested reactor and deadlock; rework this
# path before supporting that setup.
Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
end
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment is clear, but the failure mode (deadlock) is silent and extremely hard to diagnose in a production Falcon deployment. Consider making it loud instead:

def run_with_timeout(&block)
  if (task = Async::Task.current?)
    task.with_timeout(@read_timeout, &block)
  elsif defined?(Async::Reactor) && Async::Reactor.current
    # Called from inside a Falcon/async-Rails reactor without a task context.
    # Creating a nested reactor here can deadlock. Raise immediately rather than hang.
    raise ReactOnRailsPro::Error,
          "[ReactOnRailsPro] RendererHttpClient called from inside an Async reactor " \
          "without Async::Task.current?. Falcon/async-Rails deployments are not yet " \
          "supported. See the upgrade guide."
  else
    Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
  end
end

A clear error on first use beats a silent hang at peak load.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because on-demand usage is turned off. To enable Bugbot Autofix, turn on on-demand usage and set a spend limit in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 989c80d. Configure here.

connection_pool
execjs (~> 2.9)
http-2 (>= 1.1.1)
httpx (~> 1.5)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lock file missing direct io-endpoint gemspec dependency listing

Low Severity

The execjs-compatible-dummy/Gemfile.lock lists react_on_rails_pro's PATH dependencies but omits io-endpoint (~> 0.17.0), unlike the main Gemfile.lock and spec/dummy/Gemfile.lock which both include it. The gemspec declares io-endpoint as a direct runtime dependency. The gem is resolved transitively at the correct version (0.17.2) through async-http, but the missing constraint means the lock file doesn't reflect the gemspec's pinning intent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 989c80d. Configure here.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 15, 2026

Code Review

Overview

This PR replaces the Pro renderer transport layer (HTTPX to async-http) cleanly. The breaking change is well-documented throughout CHANGELOG, upgrade guide, config docs, and README. The test suite was rebuilt from scratch without HTTPX mock internals, which is the right move. The new Response wrapper with lazy streaming and replay is a solid design.


Critical / Pre-Merge Concern

Per-request client creation removes persistent connection reuse. Every renderer call (render, streaming render, asset upload) now opens a fresh TCP socket and completes an HTTP/2 handshake. On loopback this costs ~1-2 ms (per the benchmark in the PR), but in any topology where Rails and the renderer run on different hosts each request adds a full TCP + HTTP/2 handshake round-trip. At 1000 req/s that is non-trivial overhead. The PR documents this in the changelog and issue #3283, but the PR description should state clearly whether this is a known regression or expected to be neutral for the benchmark target environment.


Code Issues

1. ConnectTimeoutWrapper relies on IO::Endpoint::Wrapper internals

The subclass calls super() (no-arg) and overrides connect, relying on the parent dispatching through socket_connect. This is mitigated by the version pin and the boot-time validate_io_endpoint_version! check. However the test at renderer_http_client_spec.rb:113 encodes a version range into a spec rather than testing behavior. If io-endpoint ever releases 0.17.x+1 with compatible internals, this test would block an upgrade unnecessarily. Consider replacing it with a behavioral assertion instead.

2. pool_limit defensive fallback is unnecessary

The defined? guard in pool_limit protects against the constant not existing, but RendererHttpClient is always loaded alongside Configuration (both are part of the same gem). The constant is always present, making the fallback 10 dead code that silently diverges if DEFAULT_RENDERER_HTTP_POOL_SIZE is ever changed.

3. run_with_timeout - silent nested-reactor risk

If an async server (Falcon, etc.) calls into the renderer from inside a task that does not expose Async::Task.current?, the fallback Sync { ... } path may deadlock. The comment documents this, but a silent deadlock is hard to debug in production. Consider raising NotImplementedError in the else branch with a clear message pointing to the limitation, rather than trusting callers to avoid the situation.

4. chunk.strip silently drops whitespace from renderer output

process_response_chunks strips each chunk before yielding. This correctly handles the empty-string artifact from leading-newline chunks but also strips intentional leading/trailing whitespace from any SSR line. React SSR output rarely has significant leading whitespace on individual lines, but server-rendered <pre> blocks or text-node lines could be affected. At minimum, a comment should call this out as known behavior.

5. error_body accumulation skips newlines

In process_response_chunks, error chunks are concatenated without a separator. Since loop_response_lines strips newlines (they are the split delimiter), multi-line error responses are joined without any spacing. Consider appending "\n" after each error chunk to preserve readability of multi-line renderer errors.


Minor / Nits

  • split_url manually builds the origin string with string interpolation; URI#origin (Ruby 2.7+) does this without the manual port check.
  • Removing the "propagates close errors during reset" test is correct since close is now a no-op, but the behavioral change (close errors now silently swallowed) is worth one sentence in the PR notes.
  • The upgrade notes warn about bundle update async-http pulling a newer io-endpoint; noting this risk inline in the gemspec as a comment would help users who skip the upgrade guide.

Summary

The approach is solid and well-tested. The two items I would want addressed before merge: (1) a clear statement about whether per-request connection overhead is acceptable for the target topology or a firmer plan/timeline for #3283, and (2) the pool_limit dead-code cleanup. Everything else is minor or already documented.

end
end

validate_io_endpoint_version!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class-level call to validate_io_endpoint_version! runs at require time — not lazily. That is intentional (fail-fast), but it means any require of this file (including in unit tests that mock the HTTP client) will raise if io-endpoint is outside the pinned range. Tests that stub RendererHttpClient entirely can still be broken at load time if the constant changes. Worth a one-line comment here explaining the fail-fast intent so the call doesn't look like an accidental top-level invocation.

Async::HTTP::Endpoint.parse(origin, **options)
end

def pool_limit
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The defined? guard is defensive but unnecessary: RendererHttpClient is always loaded alongside Configuration (same gem, same load path), so DEFAULT_RENDERER_HTTP_POOL_SIZE is always available. The fallback 10 is dead code that will silently diverge if the constant's value changes. Suggest simplifying to:

Suggested change
def pool_limit
def pool_limit
return @pool_size unless @pool_size.nil?
ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE

Comment on lines +340 to +349
if (task = Async::Task.current?)
task.with_timeout(@read_timeout, &block)
else
# Rails calls this from a synchronous request thread; Sync blocks that thread
# while async-http drives the renderer exchange inside a temporary reactor.
# If an async server reaches this fallback from inside an existing reactor without
# Async::Task.current?, Sync may create a nested reactor and deadlock; rework this
# path before supporting that setup.
Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
end
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment correctly describes the nested-reactor deadlock risk, but the fallback silently takes the Sync path and deadlocks rather than raising an actionable error. For async Rails servers (Falcon, etc.) that inadvertently reach this branch, a deadlock will surface as a hung request with no stack trace pointing here.

Consider making the unsafe path explicit:

    def run_with_timeout(&block)
      if (task = Async::Task.current?)
        task.with_timeout(@read_timeout, &block)
      else
        # Sync creates a temporary reactor. If called from inside an existing reactor
        # without Async::Task.current? (e.g. a Falcon middleware shim), this may
        # deadlock. Raise immediately so the caller gets an actionable error.
        raise NotImplementedError,
              "[ReactOnRailsPro] RendererHttpClient cannot be called from inside an " \
              "existing Async reactor without a current task. Use a standard synchronous " \
              "Rails server (Puma) or wait for async-reactor support in issue #3283." \
              if inside_async_reactor?

        Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
      end
    end

Even a TODO comment explicitly saying "raise here once detection is reliable" would help future maintainers.


while (idx = line.index("\n"))
yield line.byteslice(0..idx - 1)
yield line.byteslice(0, idx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The byteslice(0, idx) fix is correct: when idx == 0 (chunk starts with \n), the old 0..idx-1 range expanded to 0..-1 (the entire buffer), so the buffered content would be duplicated. The new form yields an empty string "" instead, which is then filtered upstream by chunk.strip in process_response_chunks. The fix and the upstream filter work together correctly — worth a cross-reference comment here so the empty-yield is not later "optimized" away by a reader who doesn't know about the strip filter above.

end

if response_has_error_status?(stream_response)
error_body << chunk
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Error chunks are concatenated without a separator. Since loop_response_lines splits on \n and strips the delimiter, a renderer error body with multiple lines like:

Error: VM context failed
  at render (renderer.js:42)

becomes "Error: VM context failed at render (renderer.js:42)" — unreadable. Consider error_body << chunk << "\n" so the error body preserves line boundaries when surfaced in handle_http_error.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 15, 2026

Code Review: Switch Pro renderer client to async-http

Overall assessment: This is a significant, well-documented transport layer replacement with strong test coverage and thorough validation. The implementation is generally clean and the mock infrastructure is a genuine improvement. A few areas deserve attention before merge.


Summary of Changes

  • Replaces HTTPX persistent connection pool with request-scoped async-http clients
  • New RendererHttpClient class wraps Async::HTTP::Client with typed errors (TimeoutError, ConnectionError, HTTPError)
  • Fixes streaming chunk newline duplication bug (byteslice(0..idx-1)byteslice(0, idx))
  • Bumps minimum Ruby to 3.3; removes httpx/http-2 dependencies
  • Cleans up test mocks—removing HTTPX plugin internals in favor of a proper MockClient/MockStream abstraction

Issues

1. Request-scoped clients = no connection reuse (documented but high risk)

Every renderer request opens a new TCP + HTTP/2 connection. The PR acknowledges this in the changelog and docs, and tracks persistent connection reuse in Issue 3283. However, the benchmark numbers (1461 req/s, avg 6.73 ms) are loopback benchmarks—in production topologies where Rails and the renderer run on different hosts, each request pays a full TCP handshake + HTTP/2 SETTINGS frame exchange. For high-throughput or latency-sensitive deployments, this regression could be significant. Worth flagging prominently in the PR description and considering a fast-follow milestone target for Issue 3283.

2. Nested reactor deadlock risk (Falcon / async-rails)

The run_with_timeout fallback uses Sync { } when called outside an Async::Task.current? context. This silently creates a new Async reactor in the calling thread. The code comment correctly warns that Falcon deployments creating a nested reactor may deadlock—but it only warns in a comment rather than raising. If someone deploys on Falcon today, they'll get a mysterious deadlock instead of a clear error. Consider raising ReactOnRailsPro::Error at the Sync callsite for known-incompatible setups rather than documenting the footgun.

3. io-endpoint internal dependency is fragile

ConnectTimeoutWrapper subclasses IO::Endpoint::Wrapper and depends on Wrapper#initialize accepting no args and Wrapper#connect calling socket_connect internally. The gemspec pins io-endpoint ~> 0.17.0 to guard against this, and there's a test asserting the version is < 0.18. This is a reasonable mitigation, but it means upgrading async-http in the future requires manually re-verifying these internals. The RBS signature and test comments do acknowledge this; it's not a blocker but worth tracking in Issue 3283 or a dedicated issue.

4. Response#consume status_assigner ordering

In execute_request, status_assigner is called before stream_body, which calls yielder. For streaming responses, this means status is always set before chunks are yielded—good. But the Response class documents status as nil until the executor runs. A caller who accesses response.status during a response.each block (before the first chunk) would see the already-assigned status. This is the correct and expected behavior; it just inverts the HTTPX semantics (where status was previously non-blocking before enumeration). Worth a test asserting response.status is set after the executor calls status_assigner.call(...) but before any chunk yielding.

5. pool_limit defensive check is unnecessary

if defined?(ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE)
  ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE
else
  10
end

DEFAULT_RENDERER_HTTP_POOL_SIZE is a constant defined in the same gem. defined? checks on constants in the same codebase add noise and suggest the constant could be absent, which it can't be. The fallback 10 is also identical to the constant's value—this is fine but slightly confusing. A simple ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE would be cleaner.

6. Removed test: propagates close errors during reset

The test that verified reset_connection propagated close errors was removed because close is now a no-op. This is correct—the semantic is that request-scoped clients are just garbage collected. But the behavioral change (close errors no longer propagate) should be noted in the changelog or upgrade guide for any callers who rescue from reset_connection.


Positive Highlights

  • loop_response_lines bug fix (byteslice(0..idx - 1)byteslice(0, idx)): When idx = 0 (chunk starting with \n), the old code evaluated byteslice(0..-1) which returned the entire buffered line, duplicating it. The fix is correct.
  • Final-line flush error handling in ensure: The pending_error = $ERROR_INFO pattern to preserve the original renderer error when final-chunk flushing triggers a secondary exception is elegant.
  • MockClient/MockStream abstraction: Replacing the HTTPX plugin internals with a clean MockClient that exposes MockRequest#form directly (rather than @body.instance_variable_get(:@form)) is a meaningful improvement in test maintainability.
  • sanitize_header_param: Correctly escapes " and \ before stripping CRLF—the order matters and it's right.
  • Validation at class load time (validate_io_endpoint_version! called in the class body): Fails fast at boot rather than at first renderer request. Good defensive pattern.
  • Test coverage: 329 examples across the full Pro spec suite with 0 failures, plus a new 535-line renderer_http_client_spec.rb.

Minor

  • The comment on ConnectTimeoutWrapper#connect says "Wrapper is used only for the socket_connect hook; async-http passes it via the wrapper: option and never calls delegation methods on it directly"—this is a bit optimistic given the fragile dependency. The test that asserts VERSION < 0.18 is the right safeguard.
  • RendererHttpClient.close is documented as a no-op for adapter compatibility—this is fine but Request#reset_connection still calls it. Consider a code comment in reset_connection noting that close is intentionally a no-op for this adapter.

# If an async server reaches this fallback from inside an existing reactor without
# Async::Task.current?, Sync may create a nested reactor and deadlock; rework this
# path before supporting that setup.
Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fallback silently creates a new Async reactor in a synchronous Rails request thread, which works fine for Puma but will deadlock on Falcon/async-rails (as the comment notes). Since those deployments are explicitly unsupported, consider raising rather than silently proceeding:

Suggested change
Sync { |sync_task| sync_task.with_timeout(@read_timeout, &block) }
raise ReactOnRailsPro::Error,
"[ReactOnRailsPro] renderer requests from inside an existing Async reactor are not supported. " \
"Async Rails servers (Falcon, etc.) must not call the Node renderer from a request path that " \
"already has an Async::Task. See Issue 3283."

A loud error is safer than a mysterious deadlock for users on async-compatible servers.

Async::HTTP::Endpoint.parse(origin, **options)
end

def pool_limit
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The defined? check is unnecessary — DEFAULT_RENDERER_HTTP_POOL_SIZE is a constant defined in the same gem and will always be present. The fallback 10 is also identical to the constant's value, which is confusing because it implies the constant might not exist.

Suggested change
def pool_limit
def pool_limit
@pool_size.nil? ? ReactOnRailsPro::Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE : @pool_size
end

Protocol::HTTP2::StreamError
].freeze

class ConnectTimeoutWrapper < IO::Endpoint::Wrapper
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class directly subclasses IO::Endpoint::Wrapper and relies on two undocumented internal contracts: (1) Wrapper#initialize accepts zero arguments, and (2) Wrapper#connect calls socket_connect internally (so the super call in connect can intercept the timeout). The gemspec pins io-endpoint ~> 0.17.0 and there's a version-check test, which is the right mitigation.

One concern: clear_timeout is called on the socket before the yield socket block runs. This means any code in the block receives a socket with timeout = nil regardless of what happens inside the block. This is intentional (the connect timeout should not persist into I/O reads), but it's worth an explicit comment so future readers don't try to "fix" it:

Suggested change
class ConnectTimeoutWrapper < IO::Endpoint::Wrapper
def connect(remote_address, **options)
socket = super(remote_address, **options.merge(connect_timeout ? { timeout: connect_timeout } : {}))
# Clear the per-socket timeout immediately after connect so read/write timeouts
# are driven by the Async task timeout (ssr_timeout), not the connect timeout.
clear_timeout(socket)
return socket unless block_given?
begin
yield socket
ensure
socket.close
end
end


while (idx = line.index("\n"))
yield line.byteslice(0..idx - 1)
yield line.byteslice(0, idx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the core bug fix: when idx = 0 (chunk starts with \n), the old byteslice(0..idx - 1) evaluated as byteslice(0..-1) which returned the entire buffered line — duplicating it. byteslice(0, idx) correctly returns "" when idx = 0. The fix is correct and the new test in stream_request_spec.rb ("does not duplicate a line when a chunk starts with a newline") covers it well.

end
end

validate_io_endpoint_version!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Calling validate_io_endpoint_version! at class body load time (outside any method) means this runs the moment renderer_http_client.rb is required. This is a good pattern — it fails fast at boot/require rather than at first renderer request. Just worth noting this is intentional: the validate call is at class scope, not inside initialize.

@github-actions
Copy link
Copy Markdown
Contributor

⚠️ Bencher report chunks were too large to post as PR comments.

View the full report in the job summary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants