Skip to content

Commit 5d49db0

Browse files
justin808claude
andcommitted
Fix Content-Length mismatch and null renderingRequest errors in node renderer (#3069)
Fixes #3071 ## Problem Two related errors appear in production node renderer logs: ### Error 1: `FST_ERR_CTP_INVALID_CONTENT_LENGTH` ```json { "level": "error", "msg": "Unhandled Fastify error", "err": { "code": "FST_ERR_CTP_INVALID_CONTENT_LENGTH", "message": "Request body size did not match Content-Length", "statusCode": 400 } } ``` ### Error 2: `INVALID NIL or NULL result for rendering` ```json { "level": "error", "msg": "INVALID result for prepareResult\n\nJS code for rendering request was:\nnull\n\nEXCEPTION MESSAGE:\nINVALID NIL or NULL result for rendering" } ``` ## Root Cause Analysis These two errors are causally linked — Error 1 triggers Error 2. ### How the render request normally works 1. The Ruby gem sends an HTTP/2 POST to the node renderer with a multipart form body containing `renderingRequest` (the JavaScript code to execute in the SSR VM), optional bundle files, and metadata fields 2. Fastify's multipart parser reads the body, validates that the received bytes match the `Content-Length` header, and attaches parsed fields to `req.body` 3. The render handler extracts `req.body.renderingRequest` and passes it to the VM for server-side rendering ### What goes wrong The Ruby gem uses HTTPX with persistent HTTP/2 connections to the node renderer. HTTP/2 connections are long-lived and multiplexed, but they can become **stale** — the node renderer may close its end of the connection (due to idle timeout, restart, or resource pressure) while the Ruby side still considers it active. When the Ruby gem writes a render request into a stale connection: 1. The connection may accept only part of the request body before the transport layer detects the broken connection 2. The node renderer receives a **truncated body** — fewer bytes than the `Content-Length` header promised 3. Fastify's body parser detects the mismatch and throws `FST_ERR_CTP_INVALID_CONTENT_LENGTH` (Error 1) 4. Because the parser aborted, the multipart fields were never fully parsed — `renderingRequest` is never attached to `req.body` 5. The render handler receives `req.body.renderingRequest` as `undefined`/`null`, which propagates into `prepareResult()` → `runInVM(null, ...)` → the VM returns null → "INVALID NIL or NULL result for rendering" (Error 2) The confusing part for operators is that Error 2 looks like a JavaScript/rendering bug ("JS code for rendering request was: null"), when the real problem is a transport-layer issue that happened before any JavaScript executed. ## Changes ### 1. Prevent stale connections (root cause fix) **Ruby gem** (`request.rb`, `configuration.rb`): Add `keep_alive_timeout` (default: 30s) to the HTTPX persistent connection configuration. This tells HTTPX to close idle connections after 30 seconds, preventing the Ruby side from writing into connections that the node renderer has already closed. Configurable via `renderer_http_keep_alive_timeout` with validation (must be a positive number or nil). ### 2. Early validation with actionable diagnostics (defense in depth) **Node renderer** (`worker.ts`): Validate `renderingRequest` immediately after body parsing, before entering the render pipeline. When the field is missing, null, or empty, the renderer now returns a descriptive 400 error instead of letting null propagate through the VM: ``` Invalid "renderingRequest" field in render request. Expected a non-empty string of JavaScript to execute in the SSR VM. Received type: null. Received body keys: gemVersion, protocolVersion, railsEnv. Likely causes: request body truncation, malformed multipart form data, or Content-Length mismatch in a proxy/client. ``` This gives operators immediate insight into what happened and where to look, rather than the misleading "INVALID NIL or NULL result for rendering" message. ### 3. Specific Content-Length mismatch logging **Node renderer** (`worker.ts`): The Fastify `onError` hook now detects `FST_ERR_CTP_INVALID_CONTENT_LENGTH` specifically and logs it as "Invalid request body framing" with an actionable hint about client/proxy truncation, rather than the generic "Unhandled Fastify error" message. ### 4. Security: expanded sensitive key filtering **Node renderer** (`worker.ts`): The diagnostic message includes body keys to help debugging, but filters out sensitive field names. Expanded from just `password` to also filter `token`, `secret`, `api_key`, `auth_token`, `authorization`, and `credentials` — since these diagnostics flow through error reporters (Sentry, Honeybadger). ## Test plan - [x] Node renderer worker tests pass (23/23), including new tests for missing, null, and empty-string `renderingRequest`, and expanded sensitive key filtering - [x] Pro gem configuration spec passes (46/46), including 6 new tests for `renderer_http_keep_alive_timeout` validation - [x] RuboCop passes on all changed files - [x] ESLint and Prettier pass on all changed files - [ ] CI 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the Rails↔Node renderer transport configuration (HTTPX timeouts) and request validation/logging, which can affect SSR reliability and performance if misconfigured, but is scoped and covered by tests. > > **Overview** > Fixes SSR failures caused by stale persistent connections by adding a new Ruby config `renderer_http_keep_alive_timeout` (default `30`) and wiring it into HTTPX `keep_alive_timeout` (with validation and improved connection error context). > > Hardens the Node renderer against truncated/malformed multipart bodies by returning an actionable `400` when `renderingRequest` is missing/null/empty/array, refining the reported type for empty strings, and filtering additional sensitive body keys (including `credentials`) from diagnostics; corresponding tests are updated/added. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 818020c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added configurable HTTP keep-alive timeout for Node renderer connections (default: 30s); accepts positive numbers or nil. * **Bug Fixes** * Clarified invalid rendering-request diagnostics — empty-string inputs now reported as "empty string" for received-type messaging. * **Tests** * Added tests for keep-alive timeout validation and updated expectation for empty-string rendering-request diagnostics. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08d24d4 commit 5d49db0

6 files changed

Lines changed: 88 additions & 7 deletions

File tree

packages/react-on-rails-pro-node-renderer/src/worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,18 @@ const SENSITIVE_REQUEST_BODY_KEYS = new Set([
174174
'access_token',
175175
'accesstoken',
176176
'bearer',
177+
'credentials',
177178
]);
178179

179180
const invalidRenderingRequestMessage = (body: Record<string, unknown>) => {
180181
const { renderingRequest } = body;
181-
let renderingRequestType: string = renderingRequest === '' ? 'string (empty)' : typeof renderingRequest;
182+
let renderingRequestType: string = typeof renderingRequest;
182183
if (renderingRequest === null) {
183184
renderingRequestType = 'null';
184185
} else if (Array.isArray(renderingRequest)) {
185186
renderingRequestType = 'array';
187+
} else if (renderingRequest === '') {
188+
renderingRequestType = 'empty string';
186189
}
187190
const bodyKeys = Object.keys(body).filter((key) => !SENSITIVE_REQUEST_BODY_KEYS.has(key.toLowerCase()));
188191

packages/react-on-rails-pro-node-renderer/tests/worker.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('worker', () => {
193193

194194
expect(res.statusCode).toBe(400);
195195
expect(res.payload).toContain('Invalid "renderingRequest" field in render request.');
196-
expect(res.payload).toContain('Received type: string (empty).');
196+
expect(res.payload).toContain('Received type: empty string.');
197197
expect(res.payload).toContain('Likely causes: request body truncation');
198198
});
199199

react_on_rails_pro/lib/react_on_rails_pro/configuration.rb

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def self.configuration
1515
renderer_http_pool_size: Configuration::DEFAULT_RENDERER_HTTP_POOL_SIZE,
1616
renderer_http_pool_timeout: Configuration::DEFAULT_RENDERER_HTTP_POOL_TIMEOUT,
1717
renderer_http_pool_warn_timeout: Configuration::DEFAULT_RENDERER_HTTP_POOL_WARN_TIMEOUT,
18+
renderer_http_keep_alive_timeout: Configuration::DEFAULT_RENDERER_HTTP_KEEP_ALIVE_TIMEOUT,
1819
renderer_password: nil,
1920
tracing: Configuration::DEFAULT_TRACING,
2021
dependency_globs: Configuration::DEFAULT_DEPENDENCY_GLOBS,
@@ -44,6 +45,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
4445
DEFAULT_RENDERER_HTTP_POOL_SIZE = 10
4546
DEFAULT_RENDERER_HTTP_POOL_TIMEOUT = 5
4647
DEFAULT_RENDERER_HTTP_POOL_WARN_TIMEOUT = 0.25
48+
DEFAULT_RENDERER_HTTP_KEEP_ALIVE_TIMEOUT = 30
4749
DEFAULT_SSR_TIMEOUT = 5
4850
DEFAULT_PRERENDER_CACHING = false
4951
DEFAULT_TRACING = false
@@ -72,7 +74,7 @@ class Configuration # rubocop:disable Metrics/ClassLength
7274
:rsc_payload_generation_url_path, :rsc_bundle_js_file, :react_client_manifest_file,
7375
:react_server_client_manifest_file
7476

75-
attr_reader :concurrent_component_streaming_buffer_size
77+
attr_reader :concurrent_component_streaming_buffer_size, :renderer_http_keep_alive_timeout
7678

7779
# Sets the buffer size for concurrent component streaming.
7880
#
@@ -91,10 +93,23 @@ def concurrent_component_streaming_buffer_size=(value)
9193
@concurrent_component_streaming_buffer_size = value
9294
end
9395

96+
# Sets the keep-alive timeout (in seconds) for persistent HTTP connections to the node renderer.
97+
#
98+
# @param value [Numeric, nil] A positive number or nil (to use the HTTPX default)
99+
# @raise [ReactOnRailsPro::Error] if value is not a positive number or nil
100+
def renderer_http_keep_alive_timeout=(value)
101+
unless value.nil? || (value.is_a?(Numeric) && value.positive? && value.finite?)
102+
raise ReactOnRailsPro::Error,
103+
"config.renderer_http_keep_alive_timeout must be a finite positive number or nil"
104+
end
105+
@renderer_http_keep_alive_timeout = value
106+
end
107+
94108
def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize
95109
renderer_use_fallback_exec_js: nil, prerender_caching: nil,
96110
renderer_http_pool_size: nil, renderer_http_pool_timeout: nil,
97-
renderer_http_pool_warn_timeout: nil, tracing: nil,
111+
renderer_http_pool_warn_timeout: nil, renderer_http_keep_alive_timeout: nil,
112+
tracing: nil,
98113
dependency_globs: nil, excluded_dependency_globs: nil, rendering_returns_promises: nil,
99114
remote_bundle_cache_adapter: nil, ssr_pre_hook_js: nil, assets_to_copy: nil,
100115
renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil,
@@ -111,6 +126,7 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil,
111126
self.renderer_http_pool_size = renderer_http_pool_size
112127
self.renderer_http_pool_timeout = renderer_http_pool_timeout
113128
self.renderer_http_pool_warn_timeout = renderer_http_pool_warn_timeout
129+
self.renderer_http_keep_alive_timeout = renderer_http_keep_alive_timeout
114130
self.tracing = tracing
115131
self.rendering_returns_promises = server_renderer == "NodeRenderer" ? rendering_returns_promises : false
116132
self.dependency_globs = dependency_globs

react_on_rails_pro/lib/react_on_rails_pro/request.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,18 +422,19 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
422422
# :write_timeout
423423
# :request_timeout
424424
# :operation_timeout
425-
# :keep_alive_timeout
426425
timeout: {
427426
connect_timeout: ReactOnRailsPro.configuration.renderer_http_pool_timeout,
428-
read_timeout: ReactOnRailsPro.configuration.ssr_timeout
429-
}
427+
read_timeout: ReactOnRailsPro.configuration.ssr_timeout,
428+
keep_alive_timeout: ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout
429+
}.compact
430430
)
431431
rescue StandardError => e
432432
message = <<~MSG
433433
[ReactOnRailsPro] Error creating HTTPX connection.
434434
renderer_http_pool_size = #{ReactOnRailsPro.configuration.renderer_http_pool_size}
435435
renderer_http_pool_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_timeout}
436436
renderer_http_pool_warn_timeout = #{ReactOnRailsPro.configuration.renderer_http_pool_warn_timeout}
437+
renderer_http_keep_alive_timeout = #{ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout}
437438
renderer_url = #{url}
438439
Be sure to use a url that contains the protocol of http or https.
439440
Original error is

react_on_rails_pro/spec/dummy/spec/requests/upload_asset_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
renderer_http_pool_size: 1,
2121
renderer_http_pool_timeout: 5,
2222
renderer_http_pool_warn_timeout: 0.25,
23+
renderer_http_keep_alive_timeout: 30,
2324
renderer_request_retry_limit: 5,
2425
ssr_timeout: 5,
2526
assets_to_copy: [

react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,66 @@ def self.fetch(*)
429429
end
430430
end
431431

432+
describe ".renderer_http_keep_alive_timeout" do
433+
it "defaults to 30" do
434+
ReactOnRailsPro.configure {} # rubocop:disable Lint/EmptyBlock
435+
436+
expect(ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout).to eq(30)
437+
end
438+
439+
it "accepts positive numbers" do
440+
ReactOnRailsPro.configure do |config|
441+
config.renderer_http_keep_alive_timeout = 60
442+
end
443+
444+
expect(ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout).to eq(60)
445+
end
446+
447+
it "accepts nil" do
448+
ReactOnRailsPro.configure do |config|
449+
config.renderer_http_keep_alive_timeout = nil
450+
end
451+
452+
expect(ReactOnRailsPro.configuration.renderer_http_keep_alive_timeout).to be_nil
453+
end
454+
455+
it "raises error for zero" do
456+
expect do
457+
ReactOnRailsPro.configure do |config|
458+
config.renderer_http_keep_alive_timeout = 0
459+
end
460+
end.to raise_error(ReactOnRailsPro::Error,
461+
/must be a finite positive number or nil/)
462+
end
463+
464+
it "raises error for negative numbers" do
465+
expect do
466+
ReactOnRailsPro.configure do |config|
467+
config.renderer_http_keep_alive_timeout = -5
468+
end
469+
end.to raise_error(ReactOnRailsPro::Error,
470+
/must be a finite positive number or nil/)
471+
end
472+
473+
it "raises error for non-numeric values" do
474+
expect do
475+
ReactOnRailsPro.configure do |config|
476+
config.renderer_http_keep_alive_timeout = "30"
477+
end
478+
end.to raise_error(ReactOnRailsPro::Error,
479+
/must be a finite positive number or nil/)
480+
end
481+
482+
it "raises error for infinite values" do
483+
expect do
484+
ReactOnRailsPro.configure do |config|
485+
config.renderer_http_keep_alive_timeout = Float::INFINITY
486+
end
487+
end.to raise_error(ReactOnRailsPro::Error,
488+
/must be a finite positive number or nil/)
489+
end
490+
end
491+
432492
describe ".concurrent_component_streaming_buffer_size" do
433493
it "accepts positive integers" do
434494
ReactOnRailsPro.configure do |config|

0 commit comments

Comments
 (0)