[ 
https://issues.apache.org/jira/browse/CXF-9250?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Freeman Yue Fang reassigned CXF-9250:
-------------------------------------

    Assignee: Freeman Yue Fang

> HttpClientHTTPConduit: failed exchange without request body is re-sent 
> endlessly from the exceptionally callback (e.g. remote WSDL fetch)
> -----------------------------------------------------------------------------------------------------------------------------------------
>
>                 Key: CXF-9250
>                 URL: https://issues.apache.org/jira/browse/CXF-9250
>             Project: CXF
>          Issue Type: Bug
>          Components: Transports
>    Affects Versions: 4.2.0, 4.1.2, 4.0.8, 3.6.7, 4.1.3, 4.0.9, 3.6.8, 4.1.4, 
> 3.6.9, 4.0.10, 4.1.5, 4.0.11, 4.2.1, 3.6.11, 4.1.6, 4.1.7, 3.6.12, 4.2.2, 
> 4.2.3, 4.1.8, 4.2.4
>            Reporter: Sebastian Schubert
>            Assignee: Freeman Yue Fang
>            Priority: Major
>
> When an exchange sent through {{HttpClientHTTPConduit}} fails (TLS handshake 
> failure, connection reset, connection refused, ...) and the request had no 
> body (e.g. a GET), the conduit sends the same request again from the 
> {{future.exceptionally(...)}} callback. That request fails too, its callback 
> sends it again, and so on. The loop only ends when an exchange eventually 
> succeeds. 
> The most common trigger is the remote WSDL download done by 
> {{TransportURIResolver}} when a JAX-WS client is created with an http(s) WSDL 
> URL ({{{}Service.create(url, qname){}}} or a generated {{Service}} subclass). 
> The caller gets a normal {{{}WebServiceException{}}}, but a background resend 
> loop keeps running for every failed attempt.
> h3. Impact (observed in production)
> A JAX-WS client whose remote WSDL host started failing TLS handshakes (server 
> certificate chain not in the client truststore) produced ~150,000-200,000 
> requests/min to that host from a handful of application calls. Side effects:
>  * ~250 {{ForkJoinPool.commonPool}} workers blocked in 
> {{HttpClientHTTPConduit$HttpClientWrappedOutputStream.getResponse}} -> 
> {{{}CompletableFuture.get{}}}, called from 
> {{{}lambda$setProtocolHeaders$...{}}}; {{compensatedBlock}} spawning 
> replacement workers
>  * high CPU from repeated PKIX path building
>  * no log output: {{TransportURIResolver}} logs the failure at FINEST, and 
> the future returned by {{exceptionally}} is never observed
>  * application-level retry/circuit-breaker logic does not see the resends, 
> because they happen after the original call has already failed
> Traffic stopped immediately once the certificate was added to the truststore, 
> i.e. once the next resend succeeded.
> h3. Root cause
> {{HttpClientHTTPConduit.HttpClientWrappedOutputStream.setProtocolHeaders()}} 
> builds the request, calls {{sendAsync}} and registers:
> {code:java}
>   future = cl.sendAsync(request, handler);
>   future.exceptionally(ex -> {
>       if (pout != null) {
>           synchronized (pout) {
>               pout.notifyAll();
>           }
>       }
>       try {
>           close();
>       } catch (IOException e) {
>           ex.addSuppressed(e);
>       }
>       return null;
>   });{code}
> {{close()}} is the full {{{}HTTPConduit.WrappedOutputStream.close(){}}}:
> {code:java}
>   boolean exceptionSet = outMessage.getContent(Exception.class) != null;
>   if (!written && !exceptionSet) {
>       handleHeadersTrustCaching();   // -> setProtocolHeaders() -> 
> sendAsync(...) again
>   }
>   ...
>   handleResponse();{code}
> For a request without body, {{written}} is never set (it is only set by 
> {{{}AbstractWrappedOutputStream.write{}}}), and on the synchronous path the 
> out message never gets an {{Exception}} content. So every invocation of 
> {{close()}} from the callback runs {{handleHeadersTrustCaching()}} -> 
> {{{}setProtocolHeaders(){}}}, which sends a new request and registers a new 
> callback. The callback thread then blocks in {{handleResponse()}} -> 
> {{getResponse()}} on the new future. Each failed attempt therefore starts a 
> self-sustaining chain with roughly one blocked thread per chain.
> With {{{}URLConnectionHTTPConduit{}}}, {{setProtocolHeaders()}} only sets 
> headers on the connection object, so running it twice has no network effect. 
> In {{HttpClientHTTPConduit}} the same template hook performs the send, which 
> makes the stream's {{close()}} non-idempotent.
> The {{close()}} call in the callback was added in 3.6.7 / 4.0.8 / 4.1.2 (not 
> present in 3.6.6 / 4.0.7 / 4.1.1), likely as part of CXF-9115. 
> {{HttpClientWrappedOutputStream.exception}} is read in 
> {{setupWrappedStream()}} and {{handleNoOutput()}} but never assigned.
> Requests with a body (e.g. SOAP POST) are mostly unaffected because 
> {{written}} becomes true after the first write. However, {{written = true}} 
> is only set after {{onFirstWrite()}} returns, while the callback is already 
> registered inside {{onFirstWrite()}} -> {{{}setProtocolHeaders(){}}}. So an 
> exchange that fails inside that window can be re-sent as well.
> h3. Reproducer
> A server that accepts and immediately closes every connection, and one JAX-WS 
> {{Service.create}} with a WSDL URL pointing to it:
> {code:java}
>   import java.net.*;
>   import java.util.concurrent.atomic.AtomicInteger;
>   import javax.xml.namespace.QName;
>   import jakarta.xml.ws.Service;   // javax.xml.ws.Service on 3.6.x
>   public class ResendLoopRepro {
>       public static void main(String[] args) throws Exception {
>           AtomicInteger requests = new AtomicInteger();
>           ServerSocket server = new ServerSocket(0, 200, 
> InetAddress.getLoopbackAddress());
>           Thread acceptor = new Thread(() -> {
>               while (true) {
>                   try (Socket s = server.accept()) {
>                       requests.incrementAndGet();          // close without 
> responding
>                   } catch (Exception e) {
>                       return;
>                   }
>               }
>           });
>           acceptor.setDaemon(true);
>           acceptor.start();
>           URL wsdl = new URL("http://localhost:"; + server.getLocalPort() + 
> "/service.wsdl");
>           try {
>               Service.create(wsdl, new QName("urn:test", "TestService"));
>           } catch (Exception expected) {
>               System.out.println("Service.create failed as expected; requests 
> so far: " + requests.get());
>           }
>           Thread.sleep(2000);
>           System.out.println("Requests 2s later: " + requests.get());   // 
> keeps growing
>           System.exit(0);
>       }
>   }
>   {code}
> Observed with one {{Service}} creation:
> ||CXF / Java||requests when the call failed||requests after 2 s||
> |3.6.11 / Java 17|102|460|
> |4.1.7 / Java 21|143|442|
> Expected: 1 request (or a small bounded number); nothing after the call has 
> failed.
> A TLS server with an untrusted certificate shows the same behaviour with 
> {{{}SSLHandshakeException: PKIX path building failed{}}}.
> h3. Workarounds
>  * {{{}-Dorg.apache.cxf.transport.http.forceURLConnection=true{}}}, or the 
> contextual property {{force.urlconnection.http.conduit=true}} (note: a 
> client/request-context property does not reach the WSDL fetch in 
> {{{}TransportURIResolver{}}}, which uses a bare message)
>  * load the WSDL from the classpath instead of http(s)



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to