记一个诡异响应码HTTP 411

开发过程碰到http 411错误,记录并分析其原因。

问题记录

首先看看411是什么。MDN给出的解释如下:

服务器拒绝在没有定义 Content-Length 头的情况下接受请求。在添加了表明请求消息体长度的有效 Content-Length 头之后,客户端可以再次提交该请求。

使用不带body的POST有点类似使用一个不带参数的方法,比如说int post(void)。虽然可行,但不是好的做法,而且要注意POST请求不带body时一定要带Content-Length: 0,不然某些代理会拒绝这个POST请求。

有如下一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) throws IOException {
URL url = new URL("http://url");
HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty("Charset", "UTF-8");
httpURLConnection.setRequestProperty("Content-Type", "multipart/form-data;boundary=******");
httpURLConnection.setRequestProperty("Connection", "close");

httpURLConnection.setRequestProperty("Cookie", "A=1; U=2; UT=1; ");

System.out.println(httpURLConnection.getResponseCode());

for (Map.Entry<String, List<String>> it : httpURLConnection.getHeaderFields().entrySet()) {
System.out.println(it.getKey() + " " + it.getValue().get(0));
}

if (httpURLConnection.getResponseCode() == HttpURLConnection.HTTP_OK) {
InputStream is = httpURLConnection.getInputStream();
System.out.println(Okio.buffer(Okio.source(is)).readUtf8());
} else {
System.out.println(Okio.buffer(Okio.source(httpURLConnection.getErrorStream())).readString(Charset.forName("gb2312")));
}
}

它的运行结果非常诡异:

  • 在Genemotion上运行时会返回411,提示Length Required
  • 在PC返回200
  • 在真机上返回200

如果注释掉httpURLConnection.setChunkedStreamingMode(128 * 1024)这个调用后,在Genemotion, PC及真机上均返回200。

通常使用OkHttp或其他第三方库进行http访问,很少使用HttpURLConnection进行http访问,因为前者更方便。所以不太了解HttpURLConnection.setChunkedStreamingMode()方法的作用,另外还有一个类似的方法HttpURLConnection.setFixedLengthStreamingMode()

查了下这两个方法的作用,如下:

  • setFixedLengthStreamingMode() - 用于事先知道content length的情况下启用http request body的流式处理而不使用内部的缓存机制。 如果应用尝试写入的数据大小超过指定的大小,或者在写入数据前关闭了OutputStream,方法会抛出异常。当启用流式处理时,不会自动处理authentication和redirection。需要authentication和redirection时会抛出HttpRetryException异常。setFixedLengthStreamingMode()方法应当在URLConnection连上之前调用。
  • setChunkedStreamingMode() - 用于事先不知道content length的情况下http request body的流式处理而不使用内部的缓存机制。这种模式下,将使用chunked transfer encoding方式来发送请求体。注意,不是所有服务器都支持这一模式。当启用流式处理时,不会自动处理authentication和redirection。需要authentication和redirection时会抛出HttpRetryException异常。setChunkedStreamingMode()方法应当在URLConnection连上之前调用。

这两个方法都是用于开启streaming模式,以提高性能,所以应该跟411 Length Required应该没有直接的关系。不太明白为什么调用setChunkedStreamingMode()之后就会有问题。

可以在发生411错误时打印出响应。响应中的错误信息表示的确很可能是缺少Content-Length请求头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
Content-Length 1580
Content-Type text/html
Date Thu, 30 Aug 2018 06:49:37 GMT
Server squid/2.7.STABLE9
X-Android-Received-Millis 1534715103348
X-Android-Response-Source NETWORK 411
X-Android-Selected-Protocol http/1.1
X-Android-Sent-Millis 1534715103343
X-Cache MISS from SK-SQUIDDEV-114
X-Cache-Lookup NONE from SK-SQUIDDEV-114:8080
X-Squid-Error ERR_INVALID_REQ 0


<HTML><HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=gb2312">
<TITLE>错误:您所请求的网址(URL)无法获取</TITLE>
<STYLE type="text/css"><!--BODY{background-color:#ffffff;font-family:verdana,sans-serif}PRE{font-family:sans-serif}--></STYLE>
</HEAD><BODY>
<H1>错误</H1>
<H2>您所请求的网址(URL)无法获取</H2>
<HR noshade size="1px">
<P>
当尝试进行以下请求时:
<PRE>
POST /xxx.php HTTP/1.1
Charset: UTF-8
Content-Type: multipart/form-data;boundary=******
Connection: close
Cookie: A=1; U=2; UT=1;
Transfer-Encoding: chunked
User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.0; Google Nexus 5X - 7.0.0 - API 24 - 1080x1920 Build/NRD90M)
Host: url
Accept-Encoding: gzip
</PRE>
<P>
发生了下列的错误:
<UL>
<LI>
<STRONG>
Invalid Request
<BR>
无效的请求
</STRONG>
</UL>
<P>
Some aspect of the HTTP Request is invalid. Possible problems:
<BR>
HTTP 请求的某些方面是无效的。可能是下列问题:
<UL>
<LI>Missing or unknown request method
<BR>缺少请求方式或未知的请求方式
<LI>Missing URL
<BR>缺少网址
<LI>Missing HTTP Identifier (HTTP/1.0)
<BR>缺少 HTTP 标识(HTTP/1.0)
<LI>Request is too large
<BR>请求命令过长
<LI>Content-Length missing for POST or PUT requests
<BR>POST 或 PUT 请求缺少内容长度
<LI>Illegal character in hostname; underscores are not allowed
<BR>主机名称中包含不合法的字符;下划线是不允许的。
</UL>
</P>
<P>本缓存服务器管理员:<A HREF="mailto:SK-SQUIDDEV-114">SK-SQUIDDEV-114</A>.
</BODY></HTML>

如何打印出请求呢?我的做法很简单,使用Node + Express搭一个web服务,用同一份代码访问这个服务,观察请求头。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const express = require('express');
const cookieParser = require('cookie-parser');

const app = express();
app.use(cookieParser());

app.get('/', (req, res) => {
console.log('content-length: ', req.headers['content-length']);
res.send('Hello World!');
});
app.post('/', (req, res) => {
console.log('content-length: ', req.headers['content-length']);
console.log('user-agent: ', req.headers['user-agent']);
res.send('Hello World!');
});

app.listen(7654, '127.0.0.1', () => console.log('Example app listening on port 7654!'));
  • 当调用了setChunkedStreamingMode()方法后(无论参数是否-1),web服务无法收到请求,错误响应跟上述错误类似
  • 当没有调用setChunkedStreamingMode()方法,web服务正常收到请求,显示content-length: 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Genemotion
content-length: 0
user-agent: Dalvik/2.1.0 (Linux; U; Android 7.0; Google Nexus 5X - 7.0.0 - API 24 - 1080x1920 Build/NRD90M)
content-length: 0
user-agent: Dalvik/2.1.0 (Linux; U; Android 7.0; Google Nexus 5X - 7.0.0 - API 24 - 1080x1920 Build/NRD90M)
// Postman
content-length: 0
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
content-length: 0
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
// Java
content-length: 0
user-agent: Java/1.8.0_73
content-length: 0
user-agent: Java/1.8.0_73

推测411大概是这样发生的:Genemotion虚拟上的HttpURLConnection实现有bug,导致调用setChunkedStreamingMode()方法后没有body的POST请求中缺少Content-Length请求头,所以这个请求被代理(squid/2.7.STABLE9)拦下来了,拦截原因正是411 Length Required

HttpURLConnection

为加深对HttpURLConnection的了解,这里将Android SDK中HttpURLConnection的注释文档翻译了一遍。

HttpURLConnection是用于支持HTTP特性的URLConnection,具体参考HTTP

它的使用方式如下:

  • 调用URL.openConnection()来获取一个新的HttpURLConnection对象,并将其强制转型为HttpURLConnection
  • 准备请求。请求的最重要属性是其URI。请求头可能包括诸如credentials, preferred content types, session cookies之类的元数据
  • 请求体(可选)。如果包括请求体,则必须调用HttpURLConnection.setDoOutput(true)。通过写入getOutputStream()返回的输出流的方式来传输数据
  • 读取响应。响应头通常包括请求体content type, content length, modified date, session cookies之类的元数据。响应体可以从getInputStream()返回的输入流中读取。如果没有响应体,getInputStream()返回一个空的流
  • 断开连接。一旦读取响应体完毕,需要调用disconnect()来关闭HttpURLConnection。断开连接可以释放相关资源

以访问http://www.android.com/网站为例:

1
2
3
4
5
6
7
8
URL url = new URL("http://www.android.com/");
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
try {
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
readStream(in);
} finally {
urlConnection.disconnect();
}

HTTPS

在以”https”开头的URL上调用URL.openConnection()时会返回HttpsURLConnection,可以覆盖缺省的HostnameVerifierSSLSocketFactory。从SSLContext创建的SSLSocketFactory可以提供自定义的X509TrustManager用于验证证书链,而自定义的X509KeyManager可以提供客户端证书。

资源处理

HttpURLConnection允许5次HTTP重定向。它会从原始服务器重定向到另一个,但不支持从HTTPS重定向到HTTP或从HTTP重定向到HTTP。

如果HTTP响应中有错误发生,getInputStream()方法会抛出IOException。使用getErrorStream()读取错误响应。而响应头可以使用正常的getHeaderFields()获取。

发送内容

向web服务器上传数据时,需要调用setDoOutput(true)进行配置。

为了达到最好的性能,在事先知道请求体长度时应当调用setFixedLengthStreamingMode(),而事先无法知道请求体长度时调用setChunkedStreamingMode()。否则HttpURLConnection会被发送数据前在内存中为整个请求体分配缓冲区,浪费内存甚至可能引起OOM,并且导致数据发送延迟。

看个上传数据的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
try {
urlConnection.setDoOutput(true);
urlConnection.setChunkedStreamingMode(0);

OutputStream out = new BufferedOutputStream(urlConnection.getOutputStream());
writeStream(out);

InputStream in = new BufferedInputStream(urlConnection.getInputStream());
readStream(in);
} finally {
urlConnection.disconnect();
}

性能

HttpURLConnection返回的输入流和输出流都没有缓冲。多数调用者应当使用BufferedInputStreamBufferedOutputStream包装httpURLConnection返回的流。只做块读写的调用方可以忽略缓冲。

向服务器大量上传或下载数据时,使用流方式可以避免一次占用过多内存。除非你需要将body一次性放进内存,否则应该以流的方式进行处理(也就是说不要将整个body保存为byte数据或String)

为减少延迟,HttpURLConnection可能会为多次请求复用同一个底层的Socket。复用的结果是HTTP连接保持的时间比实际需要的时间要长一些。调用disconnect()会将Socket放回连接池。

缺省情况下HttpURLConnection要求服务器端使用gzip压缩,它能自动为getInputStream()调用方解压数据。这种情况下Content-Encoding和Content-Length两个响应头会被清除。在请求头中添加”Accept-Encoding: identity”来关闭gzip压缩。

1
urlConnection.setRequestProperty("Accept-Encoding", "identity");

指定明确的”Accept-Encoding”请求头会关闭自动解压,不会修改原始的响应头。调用方必须自己根据响应头中的Content-Type头进行必要的解压。

getContentLength()返回传输的字节数,不能作为已压缩的输入流getInputStream()中可读取的字节数。相反的,应该一直读取输入流直到数据耗尽,即InputStream.read()返回-1。

处理网络登录

一些WiFi网络会阻止用户访问,直到用户点击某个登录页面。通常是通过HTTP重定向来展示登录页。可以使用getURL()来测试连接是否被重定向。当然,这种测试只有在收到响应头后才有效,你可以调用getInputStream()getHeaderFields()来触发响应。下面的例子在检查响应是否有被重定向:

1
2
3
4
5
6
7
8
9
10
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
try {
InputStream in = new BufferedInputStream(urlConnection.getInputStream());
if (!url.getHost().equals(urlConnection.getURL().getHost())) {
// we were redirected! Kick the user out to the browser to sign on?
}
...
} finally {
urlConnection.disconnect();
}

HTTP认证

HttpURLConnection支持HTTP basic authentication。使用Authenticator来设置JVM全局的 authentication handler:

1
2
3
4
5
Authenticator.setDefault(new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password.toCharArray());
}
});

除非同时使用了HTTPS,不建议将其作为用户认证机制。特别要说明的是,用户名、密码、请求以及响应都是在网络上明文传输的。

为了在客户端和服务器端建立和维护一个长期的会话,HttpURLConnection自带一个可扩展的cookie manager。使用CookieHandler和CookieManager来管理JVM全局的cookie:

1
2
CookieManager cookieManager = new CookieManager();
CookieHandler.setDefault(cookieManager);

缺省情况下CookieManager只接受来自原始服务器的cookie rfc2616。另外两种策略是CookiePolicy.ACCEPT_ALLCookiePolicy.ACCEPT_NONE。实现CookiePolicy来自定义cookie策略。

缺省情况下CookieManager只将cookie保存在内存中。当退出JVM时会清空cookie。通过实现CookieStore来自定义如何存储cookie。

除了可以接收HTTP响应的cookie,还可以通过程序设置cookie。HTTP请求头中的cookie必须指定domain和path。

缺省情况下HttpCookie实例能用于支持RFC 2965的服务器。而很多web服务器只支持老的规范,RFC 2109。为了兼容大多数web服务器,需要将cookie版本设置为0。

举例来说,想访问法语版本的twitter,代码如下:

1
2
3
4
5
HttpCookie cookie = new HttpCookie("lang", "fr");
cookie.setDomain("twitter.com");
cookie.setPath("/");
cookie.setVersion(0);
cookieManager.getCookieStore().add(new URI("http://twitter.com/"), cookie);

HTTP Methods

缺省时HttpURLConnection使用GET方法。如果调用setDoOutput(true)方法,它将使用POST方法。还支持其他几种method,包括:OPTIONS, HEAD, PUT, DELETETRACE,可以通过setRequestMethod()方法来进行设置。

代理

缺省时HttpURLConnection直接连接原始服务器。也可以通过HTTP代理或SOCKS代理连接原始服务器。使用代理的方式是这样:调用URL.openConnection(Proxy)方法创建连接。

IPv6

HttpURLConnection支持IPv6。对于既有IPv4地址又有IPv6地址的服务器,它会尝试所有地址直到连接成功。

缓存

对于Android平台来说:Android 4.0开始添加了响应绊缓存。如何为app开启缓存可以参考android.net.http.HttpResponseCache

相关的类

  • Authenticator
  • CookieManager
  • CookieHandler
  • CookieStore

参考

411

谁说 HTTP GET 就不能通过 Body 来发送数据呢?

POST - HTTP | MDN