Skip to content

[Pro] Reconnect stale renderer keep-alive; clarify per-thread connection math (#4571)#4575

Open
AbanoubGhadban wants to merge 3 commits into
mainfrom
4571-pro-renderer-warm-connection-per-thread
Open

[Pro] Reconnect stale renderer keep-alive; clarify per-thread connection math (#4571)#4575
AbanoubGhadban wants to merge 3 commits into
mainfrom
4571-pro-renderer-warm-connection-per-thread

Conversation

@AbanoubGhadban

@AbanoubGhadban AbanoubGhadban commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the reliability + documentation regression #4571 introduced by PR #4394.

PR #4394 changed the plain-Puma (no Fiber.scheduler) non-streaming renderer path from connect-per-request to a persistent async-http client per Puma request thread, keeping a renderer connection warm across requests on that thread. Two consequences:

  1. New hard-fail on stale keep-alive. Each persistent client is built with retries: 0, so reusing a keep-alive connection the renderer, a load balancer, or a proxy has since idle-closed fails hard (ECONNRESET/EPIPE/EOF) instead of transparently reconnecting. The old connect-per-request path never hit this.
  2. renderer_http_pool_size is now a per-client, not global, cap — total warm renderer capacity scales with the number of live Puma request threads. The config docs still described the no-scheduler path as "per-request".

Fix (minimal, no new config)

  • Stale keep-alive reconnect. PersistentThreadClient now transparently reconnects and retries a request once when a warm client (≥1 prior success) hits a dropped-connection error on a replay-safe body (nil or String). Guards keep it surgical:
    • First-contact failures are not retried — a genuinely-down renderer still fails fast.
    • Consumable upload bodies (MultipartBody) are never retried — they can't be re-sent after a partial write.
    • Reachability/startup errors (ECONNREFUSED/EHOSTUNREACH/ENETUNREACH/ETIMEDOUT/SocketError) and HTTP/2 stream resets (Protocol::HTTP2::StreamError, a request-level abort) are excluded from the reconnect path and continue to flow to the existing renderer_request_retry_limit loop unchanged.
    • New STALE_KEEPALIVE_RETRY_ERRORS set scopes the retry to IOError/EOFError, ECONNRESET, EPIPE, and Protocol::HTTP::RefusedError.
  • Docs. Corrected renderer_http_pool_size docs (constant + setter, configuration-pro.md, updating.md) to describe the per-thread persistent client and clarify that total warm renderer capacity scales with live Puma request threads, not with the pool size alone. CHANGELOG entry added.

Scope decision: global cap is doc-only

#4571 suggested bounding total warm connections independent of thread count. This PR intentionally handles that as documentation rather than new config. A cross-thread cap would either be a no-op default or an arbitrary new public config contract with a silent ephemeral-fallback policy — additional surface and risk not warranted for a regression fix. (async-http clients/connections are reactor/thread-bound, which is why per-thread clients exist in the first place.)

Testing

cd react_on_rails_pro && bundle exec rspec spec/react_on_rails_pro/renderer_http_client_spec.rb70 examples, 0 failures (10 new).

Transport suite (renderer_http_client_spec, request_spec, stream_request_spec, stream_spec) — 184 examples, 0 failures, 1 pending (pre-existing).

New specs:

  • Regression spec documenting the per-thread scaling (N threads → N clients, each with the full pool_limit).
  • Reconnect specs: warm reconnect + follow-up on rebuilt client; warm GET (nil body) reconnect; not-warm first-contact no-retry; non-replayable multipart upload no-retry; second-drop no-infinite-retry; close-failure recovery; ECONNREFUSED excluded; StreamError excluded.

Also: bundle exec rubocop ... --ignore-parent-exclusion clean, bundle exec rake rbs:validate passes, git diff --check clean, Prettier clean on changed docs.

Process

Developed with Codex (gpt-5.5, xhigh) as peer reviewer at each step — reproduce, investigate, plan, implement, review, test — with agreement reached before advancing each step.

Fixes #4571.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Renderer requests now transparently recover from certain stale keep-alive idle-close failures by reconnecting and retrying once (only for failures that occur before the response is returned).
    • Automatic retries remain disabled for non-replayable request bodies and for specific connection/HTTP2 failure types; errors are surfaced as before.
  • Documentation

    • Clarified renderer_http_pool_size as a per-client connection limit and explained how warm capacity scales with active Puma threads, including different lifecycles for non-streaming vs streaming requests and updated retry expectations.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d61db69a-bd7c-446b-b619-c2e0006d5177

📥 Commits

Reviewing files that changed from the base of the PR and between 342afeb and b899342.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • docs/oss/configuration/configuration-pro.md
  • docs/pro/updating.md
  • react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
🚧 Files skipped from review as they are similar to previous changes (4)
  • react_on_rails_pro/lib/react_on_rails_pro/configuration.rb
  • CHANGELOG.md
  • docs/pro/updating.md
  • docs/oss/configuration/configuration-pro.md

Walkthrough

Persistent renderer clients now recover from selected stale keep-alive failures by reconnecting and retrying replay-safe requests once. Configuration comments, upgrade guidance, changelog entries, type declarations, and tests document and validate the updated behavior.

Changes

Renderer transport updates

Layer / File(s) Summary
Persistent client initialization and scaling
react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb, react_on_rails_pro/lib/react_on_rails_pro/configuration.rb, react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb
Persistent clients build async HTTP clients in their worker loop, track warm state, and use per-client pool limits across Puma threads and Fiber schedulers.
Stale keep-alive reconnect and retry
react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb, react_on_rails_pro/sig/react_on_rails_pro/renderer_http_client.rbs, react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb
Selected stale connection errors trigger one reconnect and retry for warm clients with replay-safe bodies; reachability, stream-reset, non-replayable, and repeated failures remain excluded.
Configuration and release documentation
docs/oss/configuration/configuration-pro.md, docs/pro/updating.md, CHANGELOG.md
Documentation describes per-client pooling, streaming and non-streaming reuse, and the new reconnect-and-retry behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RailsRequest
  participant PersistentThreadClient
  participant AsyncHTTPClient
  participant NodeRenderer
  RailsRequest->>PersistentThreadClient: submit render request
  PersistentThreadClient->>AsyncHTTPClient: send request on warm client
  AsyncHTTPClient->>NodeRenderer: reuse keep-alive connection
  NodeRenderer-->>AsyncHTTPClient: stale connection failure
  PersistentThreadClient->>AsyncHTTPClient: rebuild client
  PersistentThreadClient->>AsyncHTTPClient: retry replay-safe request once
  AsyncHTTPClient->>NodeRenderer: send request over fresh connection
  NodeRenderer-->>RailsRequest: render response
Loading

Possibly related issues

Possibly related PRs

Suggested labels: full-ci

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: stale keep-alive reconnects and per-thread connection math clarification.
Linked Issues check ✅ Passed The code and docs implement the requested stale-connection retry behavior and clarify per-thread pool sizing without adding the out-of-scope global cap.
Out of Scope Changes check ✅ Passed The changed code, docs, tests, and RBS all support the stated renderer transport fix and documentation updates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 4571-pro-renderer-warm-connection-per-thread

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.

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

+ci-run-hosted

@github-actions

Copy link
Copy Markdown
Contributor

Hosted CI Requested

Triggered 9 workflow(s) for 342afeb21f52.
Mode: optimized hosted CI (path-selected by script/ci-changes-detector).
Added ready-for-hosted-ci, so future commits will keep running optimized hosted CI until +ci-stop-hosted is used.

View progress in the Actions tab.

@github-actions github-actions Bot added the ready-for-hosted-ci Run optimized hosted GitHub CI for this PR label Jul 11, 2026
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR updates the Pro renderer client to recover from stale per-thread keep-alive connections.

  • Adds a one-time reconnect retry for warm persistent thread clients.
  • Documents renderer_http_pool_size as a per-client limit.
  • Adds specs for per-thread scaling and reconnect guard cases.
  • Updates the RBS signature and changelog.

Confidence Score: 4/5

The reconnect path needs a fix before merging.

  • Warm JSON render requests can be replayed after the renderer has already begun responding.
  • The added tests cover stale pre-response drops, but not failures while buffering a partial response.
  • The docs, RBS update, and pool-size clarification look consistent with the implementation.

react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb

Important Files Changed

Filename Overview
react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb Adds warm-client reconnect logic, with one issue where response-read failures can replay a POST.
react_on_rails_pro/sig/react_on_rails_pro/renderer_http_client.rbs Adds the RBS constant declaration for the new stale keep-alive retry error list.
react_on_rails_pro/spec/react_on_rails_pro/renderer_http_client_spec.rb Adds tests for thread scaling, warm reconnects, excluded errors, multipart bodies, and single-retry behavior.
react_on_rails_pro/lib/react_on_rails_pro/configuration.rb Clarifies that the renderer HTTP pool size is a per-client limit.
docs/oss/configuration/configuration-pro.md Updates Pro configuration docs for per-thread keep-alive behavior under standard Puma.
docs/pro/updating.md Updates upgrade guidance for per-client pool sizing and the new reconnect behavior.
CHANGELOG.md Adds a changelog entry for the stale keep-alive reconnect fix and documentation update.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant Rails as Rails render caller
  participant ThreadClient as Persistent thread client
  participant OldClient as Warm async-http client
  participant NewClient as Rebuilt async-http client
  Rails->>ThreadClient: non-streaming render request
  ThreadClient->>OldClient: send request
  OldClient--xThreadClient: stale connection error
  ThreadClient->>OldClient: close
  ThreadClient->>NewClient: build replacement client
  ThreadClient->>NewClient: retry once for nil/String body
  NewClient-->>ThreadClient: buffered response or error
  ThreadClient-->>Rails: response or ConnectionError
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant Rails as Rails render caller
  participant ThreadClient as Persistent thread client
  participant OldClient as Warm async-http client
  participant NewClient as Rebuilt async-http client
  Rails->>ThreadClient: non-streaming render request
  ThreadClient->>OldClient: send request
  OldClient--xThreadClient: stale connection error
  ThreadClient->>OldClient: close
  ThreadClient->>NewClient: build replacement client
  ThreadClient->>NewClient: retry once for nil/String body
  NewClient-->>ThreadClient: buffered response or error
  ThreadClient-->>Rails: response or ConnectionError
Loading

Reviews (1): Last reviewed commit: "Add PR and author attribution to #4571 c..." | Re-trigger Greptile

Comment thread react_on_rails_pro/lib/react_on_rails_pro/renderer_http_client.rb

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c516e957a0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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/renderer_http_client.rb
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review

Overview

This PR fixes issue #4571: the no-scheduler persistent per-thread renderer HTTP client (introduced in #4394) fails hard on a stale/idle-closed keep-alive connection instead of transparently reconnecting, and the renderer_http_pool_size docs incorrectly described the no-scheduler path as "per-request" rather than "per persistent thread client." The fix adds a bounded, one-shot reconnect-and-retry in PersistentThreadClient#handle_request, scoped to a warm client (≥1 prior success), a replay-safe body (nil/String, never MultipartBody), and a specific error set (STALE_KEEPALIVE_RETRY_ERRORS) that excludes reachability errors and HTTP/2 stream resets. Docs (configuration-pro.md, updating.md, configuration.rb) and CHANGELOG are updated accordingly, plus 10 new specs.

Strengths

  • The guard conditions are well-reasoned and mostly correctly scoped: excluding first-contact failures (fail fast on a genuinely-down renderer), excluding non-replayable multipart upload bodies, and excluding ECONNREFUSED/StreamError from the retry path are all sound calls, and each has a dedicated regression test.
  • reconnected is a local var scoped to a single handle_request call, so the retry is provably bounded to one attempt (verified by the "surfaces the error without a second retry" spec).
  • reconnect_async_client best-effort-closes the poisoned client and tolerates close failures, which is the right way to avoid a broken .close masking the real reconnect.
  • Docs changes are accurate and consistent with the code (per-thread scaling math, retry exclusions, nil pool size behavior).
  • Good regression test documenting the N-threads → N-clients / full-pool_limit-per-client behavior that motivated the doc fix.

Potential issue: retry scope also covers mid-response-body drops (see inline comment)

In handle_request, the rescue *STALE_KEEPALIVE_RETRY_ERRORS wraps both the client.post/client.get call and buffer_response(raw_response), which drains the response body via body&.each. A "stale idle keep-alive" failure (the case this PR targets) typically manifests as an immediate write failure before any bytes are exchanged, which is safe to retry. But this same rescue also catches a connection drop that happens after the renderer already returned a 200 and started streaming a response body — in that case the render has already partially/fully executed, and retrying resends the whole POST, causing the renderer to redo the work. This is a new failure mode (pre-PR, this case just failed with no retry). None of the new specs exercise a drop during body streaming (they all raise from the post/get stub itself, before any body reading), so this path isn't covered. Worth confirming whether this is acceptable given render idempotency assumptions, or narrowing the rescue to only wrap the initial client.post/client.get call.

Minor notes

  • STALE_KEEPALIVE_RETRY_ERRORS includes plain IOError (broad — same pattern as the pre-existing CONNECTION_ERRORS, so not a new concern, just worth knowing it can catch non-network IOErrors too).
  • No security or performance concerns beyond the double-render-execution scenario above; the change is transport-layer reliability code with no new external inputs.

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/renderer_http_client.rb Outdated
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review: [Pro] Reconnect stale renderer keep-alive; clarify per-thread connection math (#4571)

Overview

This is a targeted regression fix for the reliability issue introduced by #4394, which switched the plain-Puma (no Fiber.scheduler) non-streaming renderer path from connect-per-request to a persistent async-http client per Puma request thread. Because that client is built with retries: 0, reusing a keep-alive connection that's since been idle-closed by the renderer/LB/proxy previously failed hard. This PR adds a single, scoped transparent reconnect-and-retry, plus corrects the renderer_http_pool_size docs to reflect that it's a per-client (not global) cap.

Code quality / correctness

  • The retry logic in PersistentThreadClient#handle_request is well-scoped and correctly guarded:
    • Warm-only (warm flag, true only after >=1 prior success on that thread's client) - first-contact failures still fail fast, which is the right call for a genuinely-down renderer.
    • Replay-safe bodies only (nil or String) - MultipartBody upload bodies are correctly excluded since they can't be safely re-sent after a partial write. Since a String body has no read cursor, resending it after a failed attempt is safe.
    • Exactly one retry via the reconnected local - no infinite loop if the rebuilt client also drops.
    • Reachability errors (ECONNREFUSED, EHOSTUNREACH, ENETUNREACH, ETIMEDOUT, SocketError) and Protocol::HTTP2::StreamError are correctly excluded from STALE_KEEPALIVE_RETRY_ERRORS, leaving them to the existing outer renderer_request_retry_limit loop.
  • run_loop/handle_request refactor (returning [client, warm] and threading @endpoint/@protocol/@pool_limit as ivars via build_async_client) is a clean simplification over the old kwargs-passing approach and doesn't change externally-visible behavior.
  • Left two inline nits (non-blocking): a double-failure edge case in reconnect_async_client where a fresh build_async_client failure in the rescue fallback isn't caught and instead kills the worker thread (self-healing, low impact), and a note that warm never resets to false, so a sustained renderer outage after the client has warmed costs one extra doomed reconnect+retry per request rather than failing fast every time.

Test coverage

Good breadth: warm reconnect+retry (POST and GET/nil-body), not-warm no-retry, non-replayable multipart no-retry, second-drop no-infinite-retry, ECONNREFUSED/StreamError exclusion, and close-failure recovery during reconnect. The new regression spec also directly documents the per-thread pool_limit scaling behavior that motivated the doc fix. One small gap: IOError/EOFError and EPIPE are in STALE_KEEPALIVE_RETRY_ERRORS but only ECONNRESET and Protocol::HTTP::RefusedError get dedicated retry-success specs - not a blocker given they're handled identically in the same rescue clause, just worth noting.

Docs

The configuration-pro.md, updating.md, and configuration.rb comment updates are consistent with each other and accurately describe the corrected per-thread/per-client connection math and the new reconnect behavior, including its limits (non-replayable bodies, reachability errors, stream resets excluded).

Security

No security concerns - this only affects retry/reconnect behavior on an already-authenticated internal renderer connection; no new external input handling.

Scope

The decision to leave the "global cap across threads" ask from #4571 as a doc-only fix (rather than new config) is reasonable and well-justified in the PR description - avoids introducing a new public config surface for a regression fix.

Overall: solid, well-tested, appropriately scoped fix. No blocking issues found.

AbanoubGhadban and others added 3 commits July 18, 2026 17:45
…ion math (#4571)

PR #4394 changed the plain-Puma (no Fiber.scheduler) non-streaming renderer path
to a persistent async-http client per Puma request thread, keeping a renderer
connection warm across requests on that thread. That client is built with
retries: 0, so reusing a keep-alive connection the renderer, a load balancer, or
a proxy has since idle-closed fails hard instead of transparently reconnecting --
a new failure mode (issue #4571). It also means renderer_http_pool_size bounds
connections per client, not globally, which the config docs still described as
per-request.

Fix (minimal, no new config):
- PersistentThreadClient now transparently reconnects and retries a request ONCE
  when a warm client (>=1 prior success) hits a dropped-connection error on a
  replay-safe body (nil or String). The retry is skipped for first-contact
  failures (so a genuinely-down renderer still fails fast), for consumable upload
  bodies (MultipartBody, never re-sent after a partial write), and for
  reachability/startup errors and HTTP/2 stream resets (which stay with the outer
  renderer_request_retry_limit loop). New STALE_KEEPALIVE_RETRY_ERRORS set scopes
  this to IOError/EOFError, ECONNRESET, EPIPE, and RefusedError.
- Docs: correct renderer_http_pool_size to describe the per-thread persistent
  client and clarify that total warm renderer capacity scales with live Puma
  request threads, not with the pool size alone (configuration.rb,
  configuration-pro.md, updating.md, CHANGELOG).

The global-connection-cap suggestion from #4571 is intentionally handled as a
documentation change rather than new config: a cross-thread cap would either be a
no-op default or an arbitrary new public config contract with a silent
ephemeral-fallback policy.

Fixes #4571.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilures (#4575)

Review feedback on the reconnect fix (#4571):

- greptile P1 + Claude: the retry `rescue` wrapped both the send and
  `buffer_response`, so a connection drop while reading the response body (after
  the renderer had already executed the render) was retried, re-running the
  render. The `begin/rescue *STALE_KEEPALIVE_RETRY_ERRORS` now wraps only the
  send; a drop during buffering falls to the terminal rescue and surfaces as an
  error with no retry.
- Codex P2: `IO::TimeoutError` is a subclass of `IOError`, so a warm read
  timeout entered the retry branch. `retry_stale_connection?` now excludes it, so
  timeouts flow to the outer timeout/`renderer_request_retry_limit` handling.
- Claude: after a failed reconnect+retry, `warm` is reset to false so a sustained
  renderer outage stops paying a doomed reconnect on every subsequent request.
- Claude: a build failure during reconnect now surfaces via the terminal rescue
  instead of killing the worker thread.

Adds specs for the mid-body-read drop, IO::TimeoutError exclusion, warm reset,
and reconnect-build-failure worker survival. Docs/CHANGELOG note the retry only
covers failures before the renderer returns a response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@AbanoubGhadban
AbanoubGhadban force-pushed the 4571-pro-renderer-warm-connection-per-thread branch from 342afeb to b899342 Compare July 18, 2026 14:46
@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

+ci-run-hosted

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback (commit b8993428a)

Thanks all — all findings are addressed and the branch is rebased on latest main:

  • [greptile P1 / Claude] Mid-response-body drop replayed the render → the stale-keepalive retry now wraps only the send, not buffer_response. A connection drop while reading the response body surfaces as an error with no retry, so an already-executed render is never re-run.
  • [Codex P2] IO::TimeoutError was retried as a stale keep-alive → excluded from retry_stale_connection?; read timeouts now flow to the outer timeout / renderer_request_retry_limit handling.
  • [Claude] Reconnect build failure could kill the worker → it now surfaces via the terminal rescue instead of killing the worker thread.
  • [Claude] warm never reset → reset to false after a failed reconnect+retry, so a sustained outage stops paying a doomed reconnect on every subsequent request.

Added 4 regression specs (mid-body-read drop, IO::TimeoutError exclusion, warm reset, reconnect-build-failure worker survival) — renderer_http_client_spec.rb now 74 examples, 0 failures. Docs/CHANGELOG note the retry only covers failures before the renderer returns a response. RuboCop / RBS / Prettier all clean.

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Hosted CI Requested

Triggered 9 workflow(s) for b8993428a327.
Skipped 0 workflow(s) with equivalent exact-head coverage.
Mode: optimized hosted CI (path-selected by script/ci-changes-detector).
Added ready-for-hosted-ci, so future commits will keep running optimized hosted CI until +ci-stop-hosted is used.

View progress in the Actions tab.

Comment on lines +304 to +335
def handle_request(client, message, warm)
_type, method, path, headers, body, result = message
raw_response = method == :post ? client.post(path, headers:, body:) : client.get(path, headers:)
reconnected = false
begin
raw_response = method == :post ? client.post(path, headers:, body:) : client.get(path, headers:)
rescue *STALE_KEEPALIVE_RETRY_ERRORS => e
if retry_stale_connection?(warm, reconnected, e, body)
reconnected = true
# A build failure here is surfaced by the terminal rescue below, so it never kills the worker.
client = reconnect_async_client(client)
retry
end
result << [:error, e] if result
# If we reconnected and still failed, drop `warm` so a sustained renderer outage stops
# paying a doomed reconnect+retry on every subsequent request on this thread.
return [client, reconnected ? false : warm]
end

# Response acquired: a drop while buffering is not retried (see method comment). Non-stale
# send errors and buffering failures both fall through to the terminal rescue below. `warm`
# is preserved: these paths either already obtained a response (connection was healthy) or
# failed before any reconnect. The failed-reconnect case that resets `warm` is handled above;
# a reconnect *build* failure (Async::HTTP::Client.new is lazy, so this is effectively
# unreachable) also lands here, self-limited by `reconnected` to a single attempt.
result << [:ok, buffer_response(raw_response)]
[client, true]
rescue StandardError => e
raise unless result

result << [:error, e]
[client, warm]
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.

warm isn't reset if reconnect_async_client itself raises, undermining the "stop paying a doomed reconnect" guarantee.

The normal retry-failure path explicitly drops warm when a second attempt fails after reconnecting:

return [client, reconnected ? false : warm]

But if reconnect_async_client(client) (line ~313) itself raises — e.g. build_async_client fails inside its own rescue (see the rebuilding the client during reconnect fails spec) — that exception isn't caught by the inner rescue *STALE_KEEPALIVE_RETRY_ERRORS clause (already past it) and instead falls through to the method-level rescue StandardError => e at the bottom. That branch returns [client, warm] using the original warm parameter, even though reconnected was already set to true beforehand. So warm stays true forever after a rebuild failure, and every subsequent request on that Puma thread will keep attempting (and failing) a reconnect instead of failing fast — the opposite of what the comment on this method promises ("a sustained renderer outage stops paying a doomed reconnect+retry on every subsequent request").

The added spec (surfaces the error without killing the worker when rebuilding the client during reconnect fails) only asserts that ConnectionError is raised on requests 2 and 3 — it doesn't assert on Async::HTTP::Client.new call counts, so it doesn't catch this. Worth either resetting warm to false in the outer rescue when reconnected is true, or asserting/fixing this explicitly.

This is low real-world impact since Async::HTTP::Client.new is lazy and rarely raises synchronously, but it's a genuine logic gap given a test was written specifically to cover this scenario.

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review

Overview

This PR fixes a real regression from #4394: the plain-Puma non-streaming path's per-thread persistent async-http client (retries: 0) was hard-failing on stale/idle-closed keep-alive connections instead of transparently reconnecting. The fix adds a scoped, single-shot reconnect+retry in PersistentThreadClient#handle_request, guarded to only replay-safe bodies (nil/String) on warm clients (≥1 prior success), explicitly excluding reachability errors, HTTP/2 stream resets, non-replayable upload bodies, and body-read (post-response) failures. Docs (configuration-pro.md, updating.md, inline config comments) are corrected to describe renderer_http_pool_size as a per-client (not global) cap, and a CHANGELOG entry is added. No new configuration surface is introduced — a reasonable scope decision given the alternatives (arbitrary global cap vs. doc fix) called out in the PR description.

Code quality

  • The guard logic (retry_stale_connection?, STALE_KEEPALIVE_RETRY_ERRORS) is precise and well-commented — the exclusions (timeouts, ECONNREFUSED/EHOSTUNREACH/etc., Protocol::HTTP2::StreamError, non-replayable bodies) are each justified inline, which makes the intent easy to audit.
  • build_async_client/reconnect_async_client extraction is a clean refactor of the previous run_loop(endpoint:, protocol:, pool_limit:) parameter-passing into ivars — reduces duplication for the reconnect path.
  • Test coverage is thorough: warm reconnect (POST + GET/nil body), first-contact no-retry, non-replayable body no-retry, second-drop no-infinite-retry, close-failure recovery, ECONNREFUSED/StreamError exclusion, body-read-failure no-retry, and read-timeout exclusion are all covered as distinct specs.

Potential issue (see inline comment)

  • In handle_request, when retry_stale_connection? is true, reconnected = true is set before calling reconnect_async_client(client). If that call itself raises (e.g. a build failure inside its own rescue), the exception isn't caught by the inner rescue *STALE_KEEPALIVE_RETRY_ERRORS — it falls through to the method-level rescue StandardError => e, which returns [client, warm] using the original warm value rather than accounting for reconnected. This means warm can get stuck at true after a failed rebuild, so subsequent requests on that thread keep attempting (and failing) a reconnect instead of failing fast, contrary to the method's own stated intent. The new spec for this scenario (rebuilding the client during reconnect fails) doesn't assert on reconnect call counts, so it doesn't catch this. Likely low real-world impact since Async::HTTP::Client.new is lazy and rarely raises synchronously, but worth a fix or at least an explicit test assertion given a spec was already written for this exact case.

Other observations

  • Security: no new attack surface — this only changes retry/reconnect behavior on an already-authenticated internal renderer transport; no new external input paths.
  • Performance: matches intent — one bounded extra Async::HTTP::Client.new + retry only for warm/replay-safe requests, not a hot-path regression.
  • Docs are internally consistent across configuration.rb, configuration-pro.md, and updating.md, and accurately reflect the per-thread scaling behavior.

🤖 Generated with Claude Code

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

Labels

ready-for-hosted-ci Run optimized hosted GitHub CI for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pro renderer transport: warm connection per Puma thread; renderer_http_pool_size no longer caps total connections (#4394)

1 participant