23

Executive summary: I'm using the HttpsUrlConnection class in an Android app to send a number of requests, in a serial manner, over TLS. All of the requests are of the same type and are sent to the same host. At first I would get a new TCP connection for each request. I was able to fix that, but not without causing other issues on some Android versions related to the readTimeout. I'm hoping that there will be a more robust way of achieving TCP connection reuse.


Background

When inspecting the network traffic of the Android app I'm working on with Wireshark I observed that every request resulted in a new TCP connection being established, and a new TLS handshake being performed. This results in a fair amount of latency, especially if you're on 3G/4G where each round trip can take a relatively long time. I then tried the same scenario without TLS (i.e. HttpUrlConnection). In this case I only saw a single TCP connection being established, and then reused for subsequent requests. So the behaviour with new TCP connections being established was specific to HttpsUrlConnection.

Here's some example code to illustrate the issue (the real code obviously has certificate validation, error handling, etc):

class NullHostNameVerifier implements HostnameVerifier {
    @Override   
    public boolean verify(String hostname, SSLSession session) {
        return true;
    }
}

protected void testRequest(final String uri) {
    new AsyncTask<Void, Void, Void>() {     
        protected void onPreExecute() {
        }
        
        protected Void doInBackground(Void... params) {
            try {                   
                URL url = new URL("https://www.ssllabs.com/ssltest/viewMyClient.html");
            
                try {
                    sslContext = SSLContext.getInstance("TLS");
                    sslContext.init(null,
                        new X509TrustManager[] { new X509TrustManager() {
                            @Override
                            public void checkClientTrusted( final X509Certificate[] chain, final String authType ) {
                            }
                            @Override
                            public void checkServerTrusted( final X509Certificate[] chain, final String authType ) {
                            }
                            @Override
                            public X509Certificate[] getAcceptedIssuers() {
                                return null;
                            }
                        } },
                        new SecureRandom());
                } catch (Exception e) {
                    
                }
            
                HttpsURLConnection.setDefaultHostnameVerifier(new NullHostNameVerifier());
                HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();

                conn.setSSLSocketFactory(sslContext.getSocketFactory());
                conn.setRequestMethod("GET");
                conn.setRequestProperty("User-Agent", "Android");
                    
                // Consume the response
                BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String line;
                StringBuffer response = new StringBuffer();
                while ((line = reader.readLine()) != null) {
                    response.append(line);
                }
                reader.close();
                conn.disconnect();
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
        
        protected void onPostExecute(Void result) {
        }
    }.execute();        
}

Note: In my real code I use POST requests, so I use both the output stream (to write the request body) and the input stream (to read the response body). But I wanted to keep the example short and simple.

If I call the testRequest method repeatedly I end up with the following in Wireshark (abridged):

TCP   61047 -> 443 [SYN]
TLSv1 Client Hello
TLSv1 Server Hello
TLSv1 Certificate
TLSv1 Server Key Exchange
TLSv1 Application Data
TCP   61050 -> 443 [SYN]
TLSv1 Client Hello
TLSv1 Server Hello
TLSv1 Certificate
... and so on, for each request ...

Whether or not I call conn.disconnect has no effect on the behaviour.

So I initially though "Ok, I'll create a pool of HttpsUrlConnection objects and reuse established connections when possible". No dice, unfortunately, as Http(s)UrlConnection instances apparently aren't meant to be reused. Indeed, reading the response data causes the output stream to be closed, and attempting to re-open the output stream triggers a java.net.ProtocolException with the error message "cannot write request body after response has been read".

The next thing I did was to consider the way in which setting up an HttpsUrlConnection differs from setting up an HttpUrlConnection, namely that you create an SSLContext and an SSLSocketFactory. So I decided to make both of those static and share them for all requests.

This appeared to work fine in the sense that I got connection reuse. But there was an issue on certain Android versions where all requests except the first one would take a very long time to execute. Upon further inspection I noticed that the call to getOutputStream would block for an amount of time equal to the timeout set with setReadTimeout.

My first attempt at fixing that was to add another call to setReadTimeout with a very small value after I'm done reading the response data, but that seemed to have no effect at all.
What I did then was set a much shorter read timeout (a couple of hundred milliseconds) and implement my own retry mechanism that attempts to read response data repeatedly until all data has been read or the originally intended timeout has been reached.
Alas, now I was getting TLS handshake timeouts on some devices. So what I did then was to add a call to setReadTimeout with a rather large value right before calling getOutputStream, and then changing the read timeout back to a couple of hundred ms before reading the response data. This actually seemed solid, and I tested it on 8 or 10 different devices, running different Android versions, and got the desired behaviour on all of them.

Fast forward a couple of weeks and I decided to test my code on a Nexus 5 running the latest factory image (6.0.1 (MMB29S)). Now I'm seeing the same problem where getOutputStream will block for the duration of my readTimeout on every request except the first.

Update 1: A side-effect of all the TCP connections being established is that on some Android versions (4.1 - 4.3 IIRC) it's possible to run into a bug in the OS(?) where your process eventually runs out of file descriptors. This is not likely to happen under real-world conditions, but it can be triggered by automatic testing.

Update 2: The OpenSSLSocketImpl class has a public setHandshakeTimeout method that could be used to specify a handshake timeout that is separate from the readTimeout. However, since this method exists for the socket rather than the HttpsUrlConnection it's a bit tricky to invoke it. And even though it's possible to do so, at that point you're relying on implementation details of classes that may or may not be used as a result of opening an HttpsUrlConnection.

The question

It seems improbable to me that connection reuse shouldn't "just work", so I'm guessing that I'm doing something wrong. Is there anyone who has managed to reliably get HttpsUrlConnection to reuse connections on Android and can spot whatever mistake(s) I'm making? I'd really like to avoid resorting to any 3rd party libraries unless that's completely unavoidable.
Note that whatever ideas you might think of need to work with a minSdkVersion of 16.

Community
  • 1
  • 1
Michael
  • 57,169
  • 9
  • 80
  • 125
  • 1
    Why don't you try the okHTTP implementation? See link http://square.github.io/okhttp/ – Silvio Lucas Jan 14 '16 at 17:08
  • Just wait until Google start using the OpenJDK source. Then it will happen automatically. – user207421 Jan 15 '16 at 07:26
  • @EJP: Perhaps, though I wouldn't stake my life on it. And it doesn't solve my immediate problem, because Android N is still far off, and some devices will never get the upgrade. This is not just a matter of poor client performance either; it can potentially be an issue for the server during times of high load if every client is establishing lots of connections when they really only need 1 or 2. – Michael Jan 15 '16 at 07:39
  • Sorry, according to `http://developer.android.com/reference/java/net/URLConnection.html`, by default, operations never time out. So I think you don't need to call `conn.setReadTimeout` – BNK Jan 16 '16 at 07:41
  • 1
    @BNK: I don't want reads to _never_ time out. If there's more response data to read but I'm unable to read it within X amount of time, then I want it to time out. What I don't want is for subsequent `HttpsUrlConnections` that are reusing an existing TCP connection to have to wait for the duration of the readTimeout before they can be executed - even though I've closed both the input stream and output stream for the previous `HttpsUrlConnection`. – Michael Jan 17 '16 at 12:58
  • @Michael AsyncTask and UrlConnection are very slow and not recommended, if you combine okHttp with Volley, it will handle everything smoothly. – Akash Kava Jan 20 '16 at 10:25
  • 1
    @AkashKava Evidence please. They all use the same wire protocol and they are all network-bound and limited by the speed of the server. There is no reason why one library would be significantly faster than another. Also please provide your source for 'not recommended'. By whom? – user207421 Jul 06 '16 at 20:12
  • @EJP, For some reason, when I was loading images in a list, AsyncTask+UrlConnection was way too slow, but replacing it with OkHttp+Volley made a visible difference,OkHttp uses pipeline pattern, thus executing only requested tasks, Urlconnection is definitely slower compared to OkHttp. May be Reason was, Volley creates more threads where else AsyncTask has small number of threads in its pool. – Akash Kava Jul 07 '16 at 08:07

1 Answers1

1

I suggest you try reusing SSLContexts instead of creating a new one each time and changing the default for HttpURLConnection ditto. It's sure to inhibit connection pooling the way you have it.

NB getAcceptedIssuers() is not allowed to return null.

user207421
  • 305,947
  • 44
  • 307
  • 483
  • "I suggest you try reusing `SSLContexts` instead of creating a new one each time". _"The next thing I did was to consider the way in which setting up an `HttpsUrlConnection` differs from setting up an `HttpUrlConnection`, namely that you create an `SSLContext` and an `SSLSocketFactory`. **So I decided to make both of those `static` and share them for all requests**."_ "`getAcceptedIssuers()` is not allowed to return null" _"the real code obviously has certificate validation, error handling, etc"_ – Michael Jan 15 '16 at 12:41
  • @Michael I'm commenting on the code you posted. If that isn't the real code your question is futile. Please fix your question. – user207421 Jan 15 '16 at 17:54
  • I don't own the copyright to the application's code, so it's not something I can share with anyone. The code in the question is a minimal example of how to reproduce the original issue of no connection reuse at all, if anybody is interested in testing that. I then explain all the things I've attempted to change, and the results of those changes. If you feel that those explanations + the example isn't clear enough, then I suppose I could put together another example that combines the original example with those changes. It will have to wait until Monday though. – Michael Jan 15 '16 at 18:13
  • Unfortunately it's proving difficult to find a suitable server to test an updated example against. I've tried `"https://posttestserver.com/post.php"`, but it appears to terminate the TCP connections immediately after I close the input stream (or perhaps when I do `conn.disconnect`, I'm not sure). I also tried some self-hosted alternatives, like `openssl s_server` and `MockServer`, but was unable to get them to respond properly to a POST request (where "properly" means returning a response code of 200 and a response body containing some data). – Michael Jan 18 '16 at 14:18
  • (The server that I'm making the requests against in my actual code cannot be used in any example posted here for confidentiality reasons) – Michael Jan 18 '16 at 14:20
  • The code you posted doesn't have certificate validation, and therefore drew comment accordingly. You can't complain about that. – user207421 Jan 23 '16 at 21:00