Skip to content

CAMEL-26110: camel-as2 - do not pool a connection the partner closed - #28362

Merged
davsclaus merged 5 commits into
apache:mainfrom
seshuthota:CAMEL-26110-as2-connection-close
Oct 27, 2026
Merged

davsclaus merged 5 commits into
apache:mainfrom
seshuthota:CAMEL-26110-as2-connection-close

Conversation

@seshuthota

@seshuthota seshuthota commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Claude Code on behalf of seshuthota

JIRA: CAMEL-26110

Description

When an AS2 partner answers with Connection: close, the client put the closed connection back in its pool. A send within the next 2 seconds (before the pool's stale check, validateAfterInactivity) leased it and failed with NoHttpResponseException: The target server failed to respond.

AS2ClientConnection.send() released every connection with the keep-alive of its strategy: NEG_ONE for a Connection: close response, and ZERO when there was no response. In HttpClient 5 (5.6.4 and 5.6.5), PoolingHttpClientConnectionManager.release() closes a connection only for a null keep-alive, and PoolEntry.updateExpiry() keeps a non-positive one with no expiry. So both values kept the connection, the opposite of what the code intended.

The finally block of send() now releases with null, which closes the connection, when there is no response, or when HttpCore's DefaultConnectionReuseStrategy says the connection cannot be reused (Connection: close, an HTTP/1.0 response without keep-alive, a response body without a length). Otherwise it releases with the strategy's keep-alive, as before. The strategy's Connection: close branch is removed, since that case no longer reaches it.

The ZERO case did not cause a failure on its own: HttpRequestExecutor.execute() already closes the connection on an exception. It now uses null too, as davsclaus suggested on #28265, so the comment and the behaviour agree.

The body is read before the release. A connection closed on release cuts off an unread body: only the bytes already in the 8 KB input buffer can still be read, so a larger MDN of the current send could fail to parse (found in review by davsclaus). send() now reads the body right after the request (and the 407 check) and returns it as a streaming InputStreamEntity over the bytes, with the content type (parsed with ContentType.parseLenient) and encoding kept. It is not a BufferedHttpEntity, because EntityParser rejects an entity that is not streaming. A connection whose body cannot be read is closed, not pooled. This also closes the older race where another send could lease the connection while its body was still unread. The whole body is held in memory before send() returns; the parser reads it into memory afterwards anyway.

Not changed: a Keep-Alive: timeout=0 header or an httpConnectionPoolTtl of 0 still give a zero keep-alive, which keeps the connection with no expiry. Treating them as "do not keep" would change the meaning of a configured TTL, so it is left for a separate discussion.

Tests

  • New AS2ClientConnectionReuseTest (9 tests) in camel-as2-api, with a small in-test partner that counts the connections carrying a request:
    • Connection: close response, 3 sends in a row: each gets a new connection
    • HTTP/1.0 response without keep-alive: same
    • kept-alive response: the 3 sends share one connection
    • 64 KB body (far beyond the 8 KB input buffer) with Connection: close, with a content length and chunked: the full body is read, one new connection per send
    • 64 KB body on a kept-alive connection: the connection is still reused
    • truncated body (100 bytes announced, 10 sent) and a body that stalls until the read timeout: the send fails and the next send gets a new connection
    • a response with a charset the JVM does not know: the send fails as an HttpException (from EntityParser, as before) and the connection is not pooled
  • The first two fail on main with NoHttpResponseException. A mutant that always closes the connection fails the third. The large and chunked body tests fail without the body read (Socket closed), and the truncated and stalled body tests fail without the close on a failed read.
  • Against Camel's own AS2ServerConnection, 5 sends through one client:
    • The server answers an error (a 500 from its request handler) with Connection: close. On main the closed connection stayed in the pool and the second send failed with NoHttpResponseException; with the fix all 5 sends get their answer.
    • On success (200 with keep-alive), main and the fix behave the same: one pooled connection serves all 5 sends.
  • Full camel-as2 build: 116 (api) + 36 (component, 1 skipped as before) + 111 (integration) tests pass. Formatter and impsort applied; no generated files change.
  • Merged with main after CAMEL-25941: camel-as2 - support sending through an HTTP proxy #28265 (CAMEL-25941, which changed the same send() method) landed: no merge conflict. After the body read was added, all camel-as2 tests pass (116 + 36 + 111), the reuse test passed 12 of 12 repeat runs, and -Psourcecheck is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_0132kd6FojYDmowiTV4jkrvW

The client released every connection to the pool with the keep-alive of
its strategy, NEG_ONE for a Connection: close response and ZERO when
there was no response. HttpClient closes a released connection only for
a null keep-alive and keeps a non-positive one with no expiry, so a send
within 2s of a Connection: close response leased the closed connection
and failed with NoHttpResponseException. Release with a null keep-alive,
which closes the connection, when there is no response or when the
response does not allow the connection to be reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132kd6FojYDmowiTV4jkrvW
@seshuthota

seshuthota commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Could a committer please approve the CI workflows for this PR when you have time? Thanks!

Claude Code on behalf of @seshuthota

@davsclaus davsclaus left a comment

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.

TL;DR

Request changes: the diagnosis is right, and dropping the closed connection from the pool is the right fix. But the connection is now closed in send()'s finally, before the caller reads the response body. With Connection: close, an MDN that is not yet in the 8 KB input buffer can no longer be read.

Blockers

  • AS2ClientConnection.java:280: closing on release cuts off the unread response body. AS2ClientManager parses the body only after send() returns.

Other findings: 1 non-blocking (the tests use a 2-byte body, so they cannot show this). See below.

Thanks for the careful write-up. The analysis of release() with null versus ZERO/NEG_ONE in HttpClient 5.6, and the tests that fail on main, made this easy to follow.

Findings not tied to a line

  • CI: the workflows of this PR have not run yet (they wait for a committer's approval), so this review is static.

Review questions

  1. Is the response still readable after send() returns, now that a non-reusable connection is closed on release? No: see the inline comment at line 280.
  2. Does DefaultConnectionReuseStrategy.keepAlive(...) match the cases the PR describes (Connection: close, HTTP/1.0 without keep-alive, no length)? Yes. The 407 proxy path also still works: that connection is closed before release, so the pool discards it.
  3. Do the tests cover the change? They cover pooling, but not reading a body of realistic size after a Connection: close (inline comment on the test).

This review does not replace specialized AI review tools or static analysis.

Claude Code on behalf of davsclaus

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

… is released

Releasing a connection the partner closed (Connection: close) now closes it, so the
body must be read first: only what fits the 8 KB input buffer survives the close.
Read it in send() while the connection is still ours, as a streaming entity because
EntityParser requires one. A connection whose body cannot be read is closed, not
pooled. Tests cover a large, a chunked, a truncated and a stalled body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132kd6FojYDmowiTV4jkrvW
@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-as2/camel-as2-api

🔁 1 test passed only after a retry on JDK 21 (1 retried attempt)

Recovered flaky tests on JDK 21 (1)
Module Test Failed attempts First failure
dsl/camel-jbang/camel-jbang-plugin-tui MonitorStartupTest.browserOnlyServesUntilTheServerStops 1 Connection refused

ℹ️ These tests did not fail the build. Surefire retried them and they passed.
Retries are enabled project-wide by surefire.rerunFailingTestsCount in parent/pom.xml.


🔬 Scalpel shadow comparison — Scalpel: 9 of 708 tested, 27 compile-only — current: 9 all tested

Maveniverse Scalpel detected 9 affected modules (current approach: 9).

Skip-tests mode would test 9 modules (1 direct + 8 downstream), skip tests for 27 (generated code, meta-modules)

Modules Scalpel would test (9)
  • camel-as2 ← downstream of org.apache.camel:camel-as2-api
  • camel-as2-api ← components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/AS2ClientConnection.java, components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/AS2ClientConnectionReuseTest.java
  • camel-jbang-mcp ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-mcp ← downstream of org.apache.camel:camel-jbang-core
  • camel-jbang-plugin-tui ← downstream of org.apache.camel:camel-catalog
  • camel-jbang-plugin-validate ← downstream of org.apache.camel:camel-yaml-dsl-validator
  • camel-launcher-container ← downstream of org.apache.camel:camel-launcher
  • camel-yaml-dsl-validator ← downstream of org.apache.camel:camel-catalog
  • camel-yaml-dsl-validator-maven-plugin ← downstream of org.apache.camel:camel-yaml-dsl-validator
Modules with tests skipped (27)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

All tested modules (36 modules, 5m 0s total)

Total reactor time: 5m 0s

Module Duration Status
Camel :: JBang :: Plugin :: TUI 54.4s SUCCESS
Camel :: AS2 :: Component 36.4s SUCCESS
Camel :: JBang :: Plugin :: Validate 23.7s SUCCESS
Camel :: Launcher 22.7s SUCCESS
Camel :: YAML DSL :: Validator 18.6s SUCCESS
Camel :: YAML DSL 18.2s SUCCESS
Camel :: JBang :: MCP 16.5s SUCCESS
Camel :: AS2 :: API 15.9s SUCCESS
Camel :: Catalog :: Camel Catalog 14.4s SUCCESS
Camel :: Component DSL 12.0s SUCCESS
Camel :: Docs 9.2s SUCCESS
Camel :: Kamelet Main 8.9s SUCCESS
Camel :: JBang :: Plugin :: Kubernetes 7.6s SUCCESS
Camel :: JBang :: Plugin :: Testing 7.3s SUCCESS
Camel :: Catalog :: Camel Report Maven Plugin 5.9s SUCCESS
Camel :: Catalog :: Camel Route Parser 5.1s SUCCESS
Camel :: YAML DSL :: Deserializers 4.1s SUCCESS
Camel :: All Components Sync point 3.2s SUCCESS
Camel :: YAML DSL :: Validator Maven Plugin 2.6s SUCCESS
Camel :: YAML DSL :: Maven Plugins 1.9s SUCCESS
Camel :: Catalog :: Maven 1.9s SUCCESS
Camel :: Catalog :: Suggest (deprecated) 1.5s SUCCESS
Camel :: Catalog :: Dummy Component 1.2s SUCCESS
Camel :: Assembly 1.2s SUCCESS
Camel :: Coverage 0.8s SUCCESS
Camel :: JBang :: Integration tests 0.8s SUCCESS
Camel :: JBang :: Plugin :: Generate 0.7s SUCCESS
Camel :: JBang :: Plugin :: Edit 0.7s SUCCESS
Camel :: Endpoint DSL :: Support 0.6s SUCCESS
Camel :: Catalog :: Console 0.6s SUCCESS
Camel :: JBang :: Main 0.6s SUCCESS
Camel :: Launcher :: Container 0.5s SUCCESS
Camel :: JBang :: Plugin :: MCP 0.4s SUCCESS
Camel :: Endpoint DSL n/a
Camel :: Integration Tests n/a
Camel :: JBang :: Core n/a

Top 20 slowest modules:

  • Camel :: JBang :: Plugin :: TUI (54.4s)
  • Camel :: AS2 :: Component (36.4s)
  • Camel :: JBang :: Plugin :: Validate (23.7s)
  • Camel :: Launcher (22.7s)
  • Camel :: YAML DSL :: Validator (18.6s)
  • Camel :: YAML DSL (18.2s)
  • Camel :: JBang :: MCP (16.5s)
  • Camel :: AS2 :: API (15.9s)
  • Camel :: Catalog :: Camel Catalog (14.4s)
  • Camel :: Component DSL (12.0s)
  • Camel :: Docs (9.2s)
  • Camel :: Kamelet Main (8.9s)
  • Camel :: JBang :: Plugin :: Kubernetes (7.6s)
  • Camel :: JBang :: Plugin :: Testing (7.3s)
  • Camel :: Catalog :: Camel Report Maven Plugin (5.9s)
  • Camel :: Catalog :: Camel Route Parser (5.1s)
  • Camel :: YAML DSL :: Deserializers (4.1s)
  • Camel :: All Components Sync point (3.2s)
  • Camel :: YAML DSL :: Validator Maven Plugin (2.6s)
  • Camel :: YAML DSL :: Maven Plugins (1.9s)

⚙️ View full build and test results

@davsclaus davsclaus left a comment

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.

Thanks for the quick turnaround. This fixes the blocking point: the body is now read while the connection is still ours, and a connection whose body cannot be read is closed, not pooled. That also removes the older race where a concurrent send could lease a connection with an unread body. Good catch on BufferedHttpEntity: EntityParser checks isStreaming(), so an InputStreamEntity over the bytes is the right shape. The new tests cover what the earlier comment asked for. The 64 KB body (with a Content-Length, and chunked) shows the read after the close, the kept-alive case shows reuse still works, and the truncated and stalled cases each fail without the close on a failed read.

One small thing on the new lines, AS2ClientConnection.java:286. ContentType.parse(entity.getContentType()) is strict in httpcore5. A response whose Content-Type has a charset the JVM doesn't know throws UnsupportedCharsetException, which lands in the new catch, closes the connection and leaves send() as a raw RuntimeException. AS2ClientManager.sendRequest doesn't catch RuntimeExceptions, so the caller gets it unwrapped. Before this change the same response failed in EntityParser as an HttpException ("Failed to parse entity content"). Could you use ContentType.parseLenient(entity.getContentType()) here? The parser reads the Content-Type header itself, so it still reports the bad charset as before, and the "half-read body" comment stays accurate.

A nit, optional: bodyLargerThanTheInputBufferIsReadAfterTheConnectionIsClosed (and its chunked twin) reads a bit like the bug it guards against. Something like ...IsFullyReadWhenThePartnerClosesTheConnection says what it checks.

With the parseLenient change this looks ready to me. Please mark the PR ready for review when you push it.

Reviewed with Claude Code on behalf of davsclaus. This review was generated by an AI agent and may contain inaccuracies; please verify all suggestions before applying.

@seshuthota
seshuthota marked this pull request as ready for review October 27, 2026 02:31
@seshuthota

Copy link
Copy Markdown
Contributor Author

@davsclaus the feedback from your review is addressed: the response body is now read in send() before the connection is released, and a connection whose body cannot be read is closed instead of pooled. New tests cover a 64 KB body, a chunked body, a truncated body and a stalled body. CI is green. The PR is now out of draft, so it is ready for another look. @gnodet, you have worked on camel-as2 recently, so a review from you would be welcome too.

Claude Code on behalf of seshuthota

@davsclaus davsclaus left a comment

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.

TL;DR

Approve: the fix is right. A connection the partner closes, or one whose body could not be read, no longer goes back to the pool. The parseLenient change and the rename from the last round are in, and CI is green on 682f259.

Blockers

  • None

Other findings: 1 non-blocking suggestion (inline), plus a correction to my earlier review below.

Thanks for working through three rounds of review so carefully. The tests that fail on main, each tied to the case it guards, made this easy to check.

A correction to my earlier review

I looked further this round. AS2BHttpClientConnection.receiveResponseEntity already calls EntityParser.parseAS2MessageEntity(response) inside endpoint.execute(...). So a response with a Content-Type (an MDN, or an error page parsed by the default branch) is fully read into a MimeEntity before send() releases the connection, on main as well. My first-round blocker only holds for a response without a Content-Type, which the parser leaves as a raw stream. Your 64 KB tests are that case, and the body read is still needed for it. It is also the only case where the parseLenient line I asked for gets a stream: an unknown charset in the Content-Type already fails inside execute(), in the parser. That is why unknownCharset... gets its HttpException, and it would pass with ContentType.parse too. Keeping parseLenient does no harm, and the test is still a fine guard that such a connection is not pooled. Sorry for sending you after the less important path.

Review questions

  1. Does the new block read the body that is still on the socket? Only when the parser left the entity raw. For a parsed MDN it re-serializes the MimeEntity instead. See the inline suggestion.
  2. Is the release decision still right after the merge with #28265 (proxy 407, tunnel)? Yes. The 407 and failed-connect paths close the endpoint before release, and DefaultConnectionReuseStrategy decides only when there is a response.
  3. Do the tests match the cases in the PR description? Yes. All 9 cover pooling, and the body tests cover the raw-stream path.

This review does not replace specialized AI review tools or static analysis.

Claude Code on behalf of davsclaus

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

…t unread

A parsed MDN entity is already in memory; copying it serialized and parsed
it a second time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Claus Ibsen <claus.ibsen@gmail.com>

@gnodet-bot gnodet-bot left a comment

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.

LGTM. The fix is correct and the implementation is solid.

Fix assessment: The isStreaming() guard in AS2ClientConnection.send() correctly limits body buffering to raw entities that EntityParser left unread. Parsed MimeEntity instances (non-streaming) bypass the block, avoiding the double-serialize/parse issue davsclaus identified in round 3. The EntityUtils.toByteArray() + InputStreamEntity wrapping preserves streaming semantics required by EntityParser. On a failed body read, endpoint.close(CloseMode.IMMEDIATE) before rethrowing ensures the broken connection is not returned to the pool.

Root cause fix: Removing the Connection: close branch from connectionKeepAliveStrategy and replacing the ZERO/NEG_ONE keepAlive values with null (which causes PoolingHttpClientConnectionManager.release() to close and discard the connection) is the correct fix for the HttpClient 5.6.4+ behavior change. DefaultConnectionReuseStrategy.INSTANCE is a stateless thread-safe singleton.

Tests: The 9-case AS2ClientConnectionReuseTest covers the failure scenarios, including the large-body and chunked cases that would fail without the body read, and the truncated/stalled cases that verify broken connections aren't reused. The PR body documents that the first two cases fail on main, confirming pre-fix validation.

Suggestion (metadata): No port/* label is set. This is a bug fix (connection pooled after Connection: close → NoHttpResponseException on next send) that affects users on the stable branches. Consider adding port/camel-4.22.x and port/camel-4.18.x if the bug is confirmed reproducible there.

This review was generated by an AI agent, Hermès on behalf of @gnodet.

@davsclaus davsclaus added this to the 4.24.0 milestone Oct 27, 2026
@davsclaus davsclaus added the bug Something isn't working label Oct 27, 2026
@davsclaus
davsclaus merged commit d245fb7 into apache:main Oct 27, 2026
6 checks passed
@seshuthota
seshuthota deleted the CAMEL-26110-as2-connection-close branch October 27, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants