Skip to content

Issue #180: trying to fix "Stream 49 cancelled" error on multi-module Maven projects.#188

Open
tmielke wants to merge 1 commit into
Giovds:mainfrom
tmielke:issue-180
Open

Issue #180: trying to fix "Stream 49 cancelled" error on multi-module Maven projects.#188
tmielke wants to merge 1 commit into
Giovds:mainfrom
tmielke:issue-180

Conversation

@tmielke
Copy link
Copy Markdown

@tmielke tmielke commented May 28, 2026

Issue #180: trying to fix "Stream 49 cancelled" error on multi-module Maven projects.

Fix was mostly generated with AI tools and would need a proper review. With this fix, the plugin works very well so far on Maven projects with hundreds of sub-modules.

AI Analysis of the issue and the identified fix:

The error occurred due to:

  1. HTTP/2 stream lifecycle conflict: The plugin explicitly used HTTP/2, which multiplexes streams on a single connection
  2. Premature stream closure: Jackson's beanFrom() method closed the InputStream while HTTP/2 was still managing the stream
  3. Multi-module amplification: Parallel Mojo instances shared the same static HttpClient, causing connection pool saturation

Changes Made to QueryClient.java

  1. Configured HttpClient with HTTP/1.1 and proper timeouts:
  • Switched from HTTP/2 to HTTP/1.1 (avoids stream lifecycle complexity)
  • Added 30-second connect timeout
  • Added 4-thread executor pool to limit concurrent connections
  1. Fixed response handling with buffered approach:
  • Changed from ofInputStream() to ofByteArray() - buffers entire response
  • Updated toSearchResponse() to accept byte[] instead of InputStream
  • Eliminates premature stream closure issue
  1. Added request configuration:
  • 60-second per-request timeout
  • Proper Accept and User-Agent headers
  • Removed HTTP/2 version override
  1. Added retry logic with exponential backoff:
  • 3 retry attempts for transient failures
  • Exponential backoff: 1s, 2s, 4s delays
  • Proper thread interrupt handling
  • Clear error messages showing retry count

Verification

  • All 24 tests pass
  • Code formatting passes Spotless check
  • Plugin successfully built and installed
  • Backward compatible (no API changes)

Made with help from AI tools.

…module Maven projects.

Fix was mostly generated with AI tools and would need a proper review.
With this fix, the plugin works very well so far on Maven projects with
hundreds of sub-modules.

AI Analysis of the issue and the identified fix:

The error occurred due to:
  1. HTTP/2 stream lifecycle conflict: The plugin explicitly used HTTP/2, which multiplexes streams on a single connection
  2. Premature stream closure: Jackson's beanFrom() method closed the InputStream while HTTP/2 was still managing the stream
  3. Multi-module amplification: Parallel Mojo instances shared the same static HttpClient, causing connection pool saturation

  Changes Made to QueryClient.java

  1. Configured HttpClient with HTTP/1.1 and proper timeouts:
  - Switched from HTTP/2 to HTTP/1.1 (avoids stream lifecycle complexity)
  - Added 30-second connect timeout
  - Added 4-thread executor pool to limit concurrent connections

  2. Fixed response handling with buffered approach:
  - Changed from ofInputStream() to ofByteArray() - buffers entire response
  - Updated toSearchResponse() to accept byte[] instead of InputStream
  - Eliminates premature stream closure issue

  3. Added request configuration:
  - 60-second per-request timeout
  - Proper Accept and User-Agent headers
  - Removed HTTP/2 version override

  4. Added retry logic with exponential backoff:
  - 3 retry attempts for transient failures
  - Exponential backoff: 1s, 2s, 4s delays
  - Proper thread interrupt handling
  - Clear error messages showing retry count

  Verification
  - All 24 tests pass
  - Code formatting passes Spotless check
  - Plugin successfully built and installed
  - Backward compatible (no API changes)

Made with help from AI tools.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Addresses issue #180 ("Stream 49 cancelled" on multi-module Maven projects) by reworking QueryClient's HTTP handling: switching the shared HttpClient from HTTP/2 to HTTP/1.1, buffering responses instead of streaming them into Jackson, and adding bounded retries with exponential backoff.

Changes:

  • Reconfigure the static HttpClient to use HTTP/1.1, a connect timeout, and a fixed-thread executor.
  • Read the response as byte[] (instead of InputStream) before parsing, and add per-request timeout / Accept / User-Agent headers.
  • Wrap each query in a searchWithRetry loop (5 attempts, exponential backoff) replacing the previous single try/catch (Exception).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +69 to +75
} catch (IOException | InterruptedException e) {
lastException = e;

if (Thread.interrupted()) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Query interrupted", e);
}
Comment on lines +65 to +90
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
final HttpRequest request = buildHttpRequest(query);
allDependencies.addAll(client.send(request, new SearchResponseBodyHandler()).body());
return client.send(request, new SearchResponseBodyHandler()).body();
} catch (IOException | InterruptedException e) {
lastException = e;

if (Thread.interrupted()) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Query interrupted", e);
}

// Don't retry on last attempt
if (attempt < MAX_RETRIES - 1) {
final long delayMillis = INITIAL_RETRY_DELAY.toMillis() * (long) Math.pow(2, attempt);
try {
Thread.sleep(delayMillis);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Query interrupted during retry", ie);
}
}
}
return allDependencies;
} catch (Exception e) {
throw new MojoExecutionException("Failed to connect.", e);
}

throw new MojoExecutionException("Failed to query Maven Central after " + MAX_RETRIES + " attempts", lastException);
.uri(URI.create(uri))
.timeout(Duration.ofSeconds(60))
.header("Accept", "application/json")
.header("User-Agent", "outdated-maven-plugin/1.5.0")
Comment on lines +25 to +31
private static final HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(50))
.executor(Executors.newFixedThreadPool(8))
.build();
private static final int MAX_RETRIES = 5;
private static final Duration INITIAL_RETRY_DELAY = Duration.ofSeconds(1);
private static final HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(50))
.executor(Executors.newFixedThreadPool(8))
@Giovds
Copy link
Copy Markdown
Owner

Giovds commented May 29, 2026

I think the copilot has some good points to consider.

I don't mind defaults of the retry too much, as long as they are sane defaults and work as intended. Docs should match the code though.

As for the thread pool size I would argue to let the system decide the total amount with e.g. Runtime.getRuntime().availableProcessors().

Speaking of concurrency, which I'm not best at, I wonder how it interrupts with concurrency errors as to me the code runs sequentially. HTTP/2 should provide better support for this, compared to HTTP/1.1. The httpClient also uses send() and not sendAsync() where it is also sequentially called through the loop (should be blocking before entering the next request, right?).

I also think we should probably be able to fetch the plugin version dynamically, so it can not be forgotten and should always match the plugin version defined in the POM of the project using the plugin.

The exception handling seems to have some flaws indeed.

Overall I think this approach should work with some tweaks and is pretty low impact

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants