Commit 5d49db0
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
- tests
- react_on_rails_pro
- lib/react_on_rails_pro
- spec
- dummy/spec/requests
- react_on_rails_pro
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
174 | 174 | | |
175 | 175 | | |
176 | 176 | | |
| 177 | + | |
177 | 178 | | |
178 | 179 | | |
179 | 180 | | |
180 | 181 | | |
181 | | - | |
| 182 | + | |
182 | 183 | | |
183 | 184 | | |
184 | 185 | | |
185 | 186 | | |
| 187 | + | |
| 188 | + | |
186 | 189 | | |
187 | 190 | | |
188 | 191 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
196 | | - | |
| 196 | + | |
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
| |||
Lines changed: 18 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
| |||
44 | 45 | | |
45 | 46 | | |
46 | 47 | | |
| 48 | + | |
47 | 49 | | |
48 | 50 | | |
49 | 51 | | |
| |||
72 | 74 | | |
73 | 75 | | |
74 | 76 | | |
75 | | - | |
| 77 | + | |
76 | 78 | | |
77 | 79 | | |
78 | 80 | | |
| |||
91 | 93 | | |
92 | 94 | | |
93 | 95 | | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
94 | 108 | | |
95 | 109 | | |
96 | 110 | | |
97 | | - | |
| 111 | + | |
| 112 | + | |
98 | 113 | | |
99 | 114 | | |
100 | 115 | | |
| |||
111 | 126 | | |
112 | 127 | | |
113 | 128 | | |
| 129 | + | |
114 | 130 | | |
115 | 131 | | |
116 | 132 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
422 | 422 | | |
423 | 423 | | |
424 | 424 | | |
425 | | - | |
426 | 425 | | |
427 | 426 | | |
428 | | - | |
429 | | - | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
430 | 430 | | |
431 | 431 | | |
432 | 432 | | |
433 | 433 | | |
434 | 434 | | |
435 | 435 | | |
436 | 436 | | |
| 437 | + | |
437 | 438 | | |
438 | 439 | | |
439 | 440 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
Lines changed: 60 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
429 | 429 | | |
430 | 430 | | |
431 | 431 | | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
432 | 492 | | |
433 | 493 | | |
434 | 494 | | |
| |||
0 commit comments