Skip to content

[BUG] azure-core-http-vertx: VertxRequestWriteSubscriber.endRequest calls promise::fail and can throw "Result is already complete" #50964

Closed

Description

@HerrDerb

Describe the bug
VertxRequestWriteSubscriber.endRequest fails the shared request promise with Promise.fail:

// sdk/core/azure-core-http-vertx/src/main/java/com/azure/core/http/vertx/implementation/VertxRequestWriteSubscriber.java
private void endRequest(int finishingWriteSize) {
    ...
    end.get().onFailure(promise::fail);
}

The promise has other completers: VertxHttpClient fails it from the response future (responseFuture.onFailure(promise::tryFail)), and so do the write timeout and cancellation. If one of them completes the promise first, Promise.fail throws IllegalStateException: Result is already complete inside the onFailure listener. On Vert.x this reaches the context exception handler as an uncaught exception.

This bug class has been fixed twice without covering this call site: #43399 / #43402 hardened resetRequest in the same class with tryFail, and #50821 switched all completions in VertxHttpClient to tryFail/tryComplete. endRequest was left out both times. It is unchanged on main (9970d5b, 1.3.0-beta.1).

Exception or Stack Trace
We hit this exception in production on 1.2.0 through the response path in VertxHttpClient, which #50821 fixes. It shows the same mechanism: a connection close fails the promise twice.

ERROR [com.azure.core.http.vertx.VertxHttpClient] (executor-thread-31770) java.io.IOException: io.vertx.core.http.HttpClosedException: Connection was closed
ERROR [io.quarkus.vertx.core.runtime.VertxCoreRecorder] (vert.x-eventloop-thread-1) Uncaught exception received by Vert.x: java.lang.IllegalStateException: Result is already complete
  at io.vertx.core.Promise.fail(Promise.java:90)
  at io.vertx.core.impl.future.FutureImpl$2.onFailure(FutureImpl.java:117)
  at io.vertx.core.impl.future.FutureImpl$ListenerArray.onFailure(FutureImpl.java:316)
  at io.vertx.core.impl.future.FutureBase.lambda$emitFailure$1(FutureBase.java:75)
  at io.vertx.core.impl.ContextImpl.execute(ContextImpl.java:312)
  at io.vertx.core.impl.DuplicatedContext.execute(DuplicatedContext.java:168)
  at io.vertx.core.impl.future.FutureBase.emitFailure(FutureBase.java:72)
  at io.vertx.core.impl.future.FutureImpl.tryFail(FutureImpl.java:278)
  at io.vertx.core.http.impl.HttpClientRequestBase.fail(HttpClientRequestBase.java:188)
  at io.vertx.core.http.impl.HttpClientRequestBase.handleException(HttpClientRequestBase.java:183)
  at io.vertx.core.http.impl.HttpClientRequestImpl.handleException(HttpClientRequestImpl.java:90)
  at io.vertx.core.http.impl.Http1xClientConnection$StreamImpl.handleException(Http1xClientConnection.java:767)
  at io.vertx.core.http.impl.Http1xClientConnection$StreamImpl.handleClosed(Http1xClientConnection.java:774)
  at io.vertx.core.impl.ContextImpl.lambda$execute$7(ContextImpl.java:329)
  ...

We have no production stack trace for the endRequest path. The unit test below reproduces it.

To Reproduce
Steps to reproduce the behavior:

  1. Send a request with a streaming body (Flux<ByteBuffer> or InputStream), so the body goes through VertxRequestWriteSubscriber.
  2. Let the connection close (or the response timeout fire) while the request is ending, after the stream has completed.
  3. The response future and the end() future both fail. Whichever runs second calls promise.fail on a completed promise and throws.

This is timing dependent and we have not reproduced it end to end. The unit test below reproduces it deterministically.

Code Snippet
A test in the style of VertxRequestWriteSubscriberTests. It drives the subscriber signals directly, because Flux.subscribe catches a throw from onComplete and routes it to onError, which hides the bug in a test. In production the throw comes from a Vert.x future listener (when end() fails after the write callback) and reaches the context exception handler.

@Test
public void endFailureAfterPromiseFailedExternallyIsNotThrown() {
    RuntimeException externalError = new RuntimeException("Connection was closed");
    Promise<HttpResponse> promise = Promise.promise();
    Supplier<Future<Void>> end = () -> {
        promise.fail(externalError);
        return Future.failedFuture(new RuntimeException("End error"));
    };

    VertxRequestWriteSubscriber subscriber = new VertxRequestWriteSubscriber(IGNORED_EXCEPTION_HANDLER,
        IGNORED_DRAIN_HANDLER, ignored -> Future.succeededFuture(), () -> false, IGNORED_RESET, end, promise, null,
        Context.empty());

    subscriber.onSubscribe(Operators.emptySubscription());
    subscriber.onNext(ByteBuffer.wrap(DATA));
    assertDoesNotThrow(subscriber::onComplete);
    assertSame(externalError, promise.future().cause());
}

We ran the equivalent against the main source (9970d5b) with Vert.x 4.5.34: onComplete throws IllegalStateException: Result is already complete. With promise::tryFail it does not throw, and the promise keeps the original cause.

Suggested fix:

end.get().onFailure(promise::tryFail);

Like resetRequest, the losing error could also be attached as a suppressed exception if that is preferred.

Expected behavior
The first completion of the promise wins. Later failures are ignored or attached as suppressed exceptions, and no uncaught exception reaches the Vert.x context.

Screenshots
N/A

Setup (please complete the following information):

  • OS: Linux (container on Kubernetes)
  • IDE: N/A
  • Library/Libraries: com.azure:azure-core-http-vertx:1.2.0, com.azure:azure-core:1.60.0, com.azure:azure-storage-file-share:12.32.0, io.vertx:vertx-core:4.5.34
  • Java version: 25
  • App Server/Environment: GraalVM native image
  • Frameworks: Quarkus 3.40.1

Additional context
The upload or request itself still fails or succeeds correctly. The issue is the extra uncaught exception, which shows up as an error in logs and error trackers and hides the real cause.

Information Checklist

  • Bug Description Added
  • Repro Steps Added
  • Setup information Added

Activity

  1. github-actions commented on Oct 23, 2026

    @github-actions
    Contributor

    🎯 Agentic Issue Triage

    Summary: VertxRequestWriteSubscriber.endRequest uses promise::fail on a shared promise that other paths may already have completed, causing an uncaught IllegalStateException: Result is already complete. The author suggests promise::tryFail.

    📋 Issue Details

    Package: com.azure:azure-core-http-vertx (1.2.0 reported; 1.3.0-beta.1 on main)
    Affected API: VertxRequestWriteSubscriber.endRequest
    Scenarios:

    • Streaming request body; connection closes or response timeout fires while the request is ending
    • Response future and end() future both fail, and the second promise.fail throws

    Root ask: Change to tryFail (as in resetRequest and #50821).

    🔎 Debugging / Reproduction Notes

    The report includes a deterministic unit test. The fix looks small and consistent with earlier hardening (#43399 / #43402, #50821). The deprecation CSV could not be fetched in this run. The package is in active development in the repo (1.3.0-beta.1), so it is assumed not deprecated.

    Suggested investigation steps:

    1. Confirm endRequest still uses promise::fail on main.
    2. Switch to tryFail and consider attaching the suppressed exception.
    3. Add the reported test to VertxRequestWriteSubscriberTests and update the CHANGELOG.
    🏷️ Label Confidence
    • Category: Client — data-plane SDK code behavior
    • Service: Azure.Core — package is under sdk/core
    • Confidence: High — the package is named explicitly and there is no ambiguity.
    • Author is external (read permission, not an Azure org member), so customer-reported and question were added.
    👥 Owner Routing
    • Matched CODEOWNERS entry: # ServiceLabel: %Azure.Core (line 56) — all labels matched
    • AzureSdkOwners: @alzimmermsft
    • ServiceOwners: none listed
    • Routing action: assigned the owner, added needs-team-attention
    • Scan notes: %Mgmt (line 945) did not match because the category is Client; %Azure.Core.V2 and %Azure.Core.AMQP are different labels.

    Generated by Agentic Triage for #50964 · auto · 21.1 AIC · ⊞ 16.3K · ◷

  2. github-actions commented on Oct 23, 2026

    @github-actions
    Contributor

    Thank you for your feedback. Tagging and routing to the team member(s) best able to assist.

    Generated by Agentic Triage for #50964 · auto · 21.1 AIC · ⊞ 16.3K · ◷

  3. added
    questionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that
    ClientThis issue points to a problem in the data-plane of the library.
    customer-reportedIssues that are reported by GitHub users external to the Azure organization.
    needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK team
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Azure.Coreazure-coreClientThis issue points to a problem in the data-plane of the library.customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK teamquestionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions