HttpURLConnection 自动 重复 提交 POST

一、问题

测试环境偶尔反馈有个服务 HTTP 请求重复提交。这个 HTTP 请求头里有个 UUID 作为唯一标识,会存入日志表里作为主键的,因为主键冲突,服务端是有打印异常信息的,但客户端完全正常,没有任何错误信息,业务逻辑也正常完成了。

通过查看客户端、服务端两边的日志,怀疑是 HttpURLConnection 自动进行了请求重试。在网上搜索一番,发现已经有人提了 bug JDK-6427251 ,按照这个的操作步骤是可以复现的,包括 JDK 8 的版本。

HttpURLConnection 采用 Sun 私有的一个 HTTP 协议实现类: HttpClient.java

关键是下面这段发送请求、解析响应头的方法:

  569       /** Parse the first line of the HTTP request.  It usually looks
  570           something like: "HTTP/1.0 <number> comment\r\n". */
  571   
  572       public boolean parseHTTP(MessageHeader responses, ProgressSource pi, HttpURLConnection httpuc)
  573       throws IOException {
  574           /* If "HTTP/*" is found in the beginning, return true.  Let
  575            * HttpURLConnection parse the mime header itself.
  576            *
  577            * If this isn't valid HTTP, then we don't try to parse a header
  578            * out of the beginning of the response into the responses,
  579            * and instead just queue up the output stream to it's very beginning.
  580            * This seems most reasonable, and is what the NN browser does.
  581            */
  582   
  583           try {
  584               serverInput = serverSocket.getInputStream();
  585               if (capture != null) {
  586                   serverInput = new HttpCaptureInputStream(serverInput, capture);
  587               }
  588               serverInput = new BufferedInputStream(serverInput);
  589               return (parseHTTPHeader(responses, pi, httpuc));
  590           } catch (SocketTimeoutException stex) {
  591               // We don't want to retry the request when the app. sets a timeout
  592               // but don't close the server if timeout while waiting for 100-continue
  593               if (ignoreContinue) {
  594                   closeServer();
  595               }
  596               throw stex;
  597           } catch (IOException e) {
  598               closeServer();
  599               cachedHttpClient = false;
  600               if (!failedOnce && requests != null) {
  601                   failedOnce = true;
  602                   if (httpuc.getRequestMethod().equals("POST") && (!retryPostProp || streaming)) {
  603                       // do not retry the request
  604                   }  else {
  605                       // try once more
  606                       openServer();
  607                       if (needsTunneling()) {
  608                           httpuc.doTunneling();
  609                       }
  610                       afterConnect();
  611                       writeRequests(requests, poster);
  612                       return parseHTTP(responses, pi, httpuc);
  613                   }
  614               }
  615               throw e;
  616           }
  617   
  618       }

在第 600 – 614 行的代码里:

  • failedOnce 默认是 false,表示是否已经失败过一次了。这也就限制了最多发送 2 次请求。
  • httpuc 是请求相关的信息。
  • retryPostProp 默认是 true,可以通过命令行参数(-Dsun.net.http.retryPost=false)来指定值。
  • streaming:默认 falsetrue if we are in streaming mode (fixed length or chunked) 。

通过 Linux 的命令 socat tcp4-listen:8080,fork,reuseaddr system:"sleep 1"\!\!stdout 建立一个只接收请求、不返回响应的 HTTP 服务器。
对于 POST 请求,第一次请求发送出去后解析响应会碰到流提前结束,这是个 SocketException: Unexpected end of file from serverparseHTTP 捕获后发现满足上面的条件就会进行重试。服务端就会收到第二个请求。

继续阅读

Java Socket HTTP

本文最早发表在ITeye上的个人博客http://wen866595.iteye.com/blog/1168658,现在移植到这里,也是一种记忆。

用Java Socket 实现发送HTTP GET请求和读取响应。


public class LearnHttp {
    private static final byte CR = '\r';
    private static final byte LF = '\n';
    private static final byte[] CRLF = {CR, LF};

    public static void main(String[] args) throws UnknownHostException, IOException {
        new LearnHttp().testHttp();
    }
    
    public void testHttp() throws UnknownHostException, IOException {
        String host = "www.baidu.com";
        Socket socket = new Socket(host, 80);
        
        OutputStream out = socket.getOutputStream();
        InputStream in = socket.getInputStream();
        
        // 在同一个TCP连接里发送多个HTTP请求
        for(int i = 0; i < 2; i++) {
            writeRequest(out, host);
            readResponse(in);
            System.out.println("\n\n\n");
        }
    }
    
    private void writeRequest(OutputStream out, String host) throws IOException {
        // 请求行
        out.write("GET /index.html HTTP/1.1".getBytes());
        out.write(CRLF);        // 请求头的每一行都是以CRLF结尾的
        
        // 请求头
        out.write(("Host: " + host).getBytes()); // 此请求头必须
        out.write(CRLF);

        out.write(CRLF);        // 单独的一行CRLF表示请求头的结束

        // 可选的请求体。GET方法没有请求体
        
        out.flush();
    }

    private void readResponse(InputStream in) throws IOException {
        // 读取状态行
        String statusLine = readStatusLine(in);
        System.out.println("statusLine :" + statusLine);
        
        // 消息报头
        Map headers = readHeaders(in);
        
        int contentLength = Integer.valueOf(headers.get("Content-Length"));
        
        // 可选的响应正文
        byte[] body = readResponseBody(in, contentLength);
        
        String charset = headers.get("Content-Type");
        if(charset.matches(".+;charset=.+")) {
            charset = charset.split(";")[1].split("=")[1];
        } else {
            charset = "ISO-8859-1";     // 默认编码
        }
        
        System.out.println("content:\n" + new String(body, charset));
    }
    
    private byte[] readResponseBody(InputStream in, int contentLength) throws IOException {
        
        ByteArrayOutputStream buff = new ByteArrayOutputStream(contentLength);
        
        int b;
        int count = 0;
        while(count++ < contentLength) {
            b = in.read();
            buff.write(b);
        }
        
        return buff.toByteArray();
    }
    
    private Map readHeaders(InputStream in) throws IOException {
        Map headers = new HashMap();
        
        String line;
        
        while(!("".equals(line = readLine(in)))) {
            String[] nv = line.split(": ");     // 头部字段的名值都是以(冒号+空格)分隔的
            headers.put(nv[0], nv[1]);
        }
        
        return headers;
    }
    
    private String readStatusLine(InputStream in) throws IOException {
        return readLine(in);
    }
    
    /**
     * 读取以CRLF分隔的一行,返回结果不包含CRLF
     */
    private String readLine(InputStream in) throws IOException {
        int b;
        
        ByteArrayOutputStream buff = new ByteArrayOutputStream();
        while((b = in.read()) != CR) {
            buff.write(b);
        }
        
        in.read();      // 读取 LF
        
        String line = buff.toString();
        
        return line;
    }
    
}

值得记下的教训:

InputStream.read()返回-1表示流的结束,即流被关闭了。在这次实现同一个Socket发送多个HTTP请求的过程中,一开始总是以为一个响应报文的结束是在InputStream.read返回-1时,导致第一次读取响应时总是很久才结束,第二次的请求虽然发出去了,但响应总是空的。

读取第一次请求的响应要很久是因为,这是在等待服务器关闭连接,百度的基本上是60秒。第二次是空的是因为,请求虽然发出去了,但服务器写响应的流已经被关闭,所以也读不到响应。

导致这个问题的根本原因在于没有记住:TCP是面向流的,并不知道传输的消息的开始和结束,这是由上层的协议去控制的,这里是HTTP协议。

2013-09-01:这是当时尝试HTTP pipeline,由于百度服务器不支持而失败。


欢迎关注我的微信公众号: coderbee笔记,可以更及时回复你的讨论。