Java的URLConnection有兩種逾時(Timeout)設定,分別是建立連結(Connect)時的逾時-ConnectTimeout,以及傳遞串流資料(Stream)時的逾時-ReadTimeout。當Timeout發生時,URLConnection會拋出SocketTimeoutException,並立即中止連線。但是最近筆者發現HttpsURLConnection(或是URLConnection本身)使用POST Request(或是其他Request)發生ReadTimeout(或是ConnectTimeout)時,在拋出SocketTimeoutException前又會自動重新重新嘗試(Retry)同樣的POST Request,導致伺服器出現了兩次同樣的Request紀錄。



發生過程

事情是這樣的發生的,筆者在Android上使用HttpsURLConnection傳送POST Request給Server,但是Server處理Request時花的時間太長,太久沒有回應,使得HttpsURLConnection拋出SocketTimeoutException。理論上連線應該只有一次才對,但是Server竟然收到兩次同樣的Request。而且第二次的Request,似乎是在別的執行緒(未知的執行緒)上執行的,因此無法得知Request的結果。第二次的Request不管有沒有成功,都不會再有第三次的Request。

推測原因

單就問題的發生過程來看,Android的Java Runtime環境可能有BUG,會導致HttpsURLConnection傳送POST Request時,在因發生ReadTimeout而拋出SocketTimeoutException前,自動以未知的執行緒重新嘗試傳送同樣的Request。

最遭的情況是,任意平台下的Java Runtime環境可能都有這個BUG,會導致URLConnection傳送任意的Request時,在因發生ReadTimeout或ConnectTimeout而拋出SocketTimeoutException前,自動以未知的執行緒重新嘗試傳送同樣的Request。

但是筆者在PC上以HttpURLConnection來測試ReadTimeout,並沒有發生Retry的狀況,所以也許情況並沒有那麼遭。

Google搜尋了一下,在Stack Overflow上也有人遇到一樣的問題,請參考以下圖片:

處理方式與結論

Stack Overflow上發問者的解決方式是使用Apache的HttpClient來代替HttpsURLConnection,HttpClient能夠設定是否要Retry。但如果還是要使用HttpsURLConnection的話,建議手動實作Timeout,避免在SocketTimeoutException時發出兩次同樣的Request,程式如下:

try {
    final URL url = new URL(urlString);
    final HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setReadTimeout(0); //禁用Timeout
    conn.setConnectTimeout(0); //禁用Timeout
    conn.setDoInput(true);
    conn.setDoOutput(true);
    conn.setRequestMethod("POST"); //以POST為例
    conn.setRequestProperty("Content-Length", String.valueOf(postData.getBytes(DECODER).length));

    //開新的Thread來計時
    connecting = true; //是否正在連結的旗標
    new Thread() {
        @Override
        public void run() {
            try {
                Thread.sleep(TIMEOUT); //TIMEOUT為逾時的時間(毫秒)
                while (connecting) { //不斷嘗試斷開連結,直到連結真的完全斷開
                    conn.disconnect();
                    Thread.sleep(200);
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }.start();
    //上傳POST的資料
    final DataOutputStream dos = new DataOutputStream(conn.getOutputStream());
    dos.writeBytes(postData); //postData為字串,例如:"id=10&page=1"
    dos.flush();
    dos.close();
    //下載Server回傳的結果
    final BufferedInputStream bis = new BufferedInputStream(conn.getInputStream());
    final ByteArrayOutputStream bao = new ByteArrayOutputStream();
    int c;
    byte[] buffer = new byte[2048];
    while ((c = bis.read(buffer)) >= 0) {
        bao.write(buffer, 0, c);
    }
    bis.close();
    String text = new String(bao.toByteArray(), DECODER);
    bao.close();
    System.out.println(text);
} catch (Exception ex) {
    ex.printStackTrace();
} finally {
    connecting = false;
}

使用ByteArrayOutputStream可以避免InputStreamReader可能造成的BUG,詳細請看這篇