Repository: encode/httpx

Issue:
h11._util.LocalProtocolError: Too little data for declared Content-Length
client POC:

```python
import asyncio
import logging
import httpx

logging.basicConfig(level=logging.DEBUG)


async def test():
    client = httpx.AsyncClient()
    url = 'http://127.0.0.1:8000/debug'
    resp = await client.post(url, data='debug', allow_redirects=True)
    print(resp.content)


if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(test())
```

app POC:

```python
from starlette.responses import RedirectResponse
from starlette.applications import Starlette
import uvicorn

app = Starlette(debug=True)

@app.route('/debug', methods=['POST', 'GET'])
async def debug(request):
    return RedirectResponse(url='https://httpbin.org/headers', status_code=302)

if __name__ == '__main__':
    uvicorn.run(app)
```

log:
```
DEBUG:asyncio:Using selector: KqueueSelector
DEBUG:httpx.dispatch.connection_pool:new_connection connection=HTTPConnection(origin=Origin(scheme='http' host='127.0.0.1' port=8000))
DEBUG:httpx.dispatch.connection:start_connect host='127.0.0.1' port=8000 timeout=TimeoutConfig(timeout=5.0)
DEBUG:httpx.dispatch.connection:connected http_version='HTTP/1.1'
DEBUG:httpx.dispatch.http11:send_headers method='POST' target='/debug' headers=Headers({'host': '127.0.0.1:8000', 'user-agent': 'python-httpx/0.7.2', 'accept': '*/*', 'content-length': '5', 'accept-encoding': 'gzip, deflate', 'connection': 'keep-alive'})
DEBUG:httpx.dispatch.http11:receive_event event=NEED_DATA
DEBUG:httpx.dispatch.http11:send_data data=Data(<5 bytes>)
DEBUG:httpx.dispatch.http11:receive_event event=Response(status_code=302, headers=[(b'date', b'Tue, 03 Sep 2019 03:50:53 GMT'), (b'server', b'uvicorn'), (b'location', b'https://httpbin.org/headers'), (b'transfer-encoding', b'chunked')], http_version=b'1.1', reason=b'Found')
DEBUG:httpx.dispatch.http11:receive_event event=EndOfMessage(headers=[])
DEBUG:httpx.dispatch.http11:response_closed our_state=DONE their_state=DONE
DEBUG:httpx.dispatch.connection_pool:release_connection connection=HTTPConnection(origin=Origin(scheme='http' host='127.0.0.1' port=8000))
DEBUG:httpx.dispatch.connection_pool:new_connection connection=HTTPConnection(origin=Origin(scheme='https' host='httpbin.org' port=443))
DEBUG:httpx.dispatch.connection:start_connect host='httpbin.org' port=443 timeout=TimeoutConfig(timeout=5.0)
DEBUG:httpx.dispatch.connection:connected http_version='HTTP/1.1'
DEBUG:httpx.dispatch.http11:send_headers method='GET' target='/headers' headers=Headers({'host': 'httpbin.org', 'user-agent': 'python-httpx/0.7.2', 'accept': '*/*', 'content-length': '5', 'accept-encoding': 'gzip, deflate', 'connection': 'keep-alive'})
DEBUG:httpx.dispatch.http11:receive_event event=NEED_DATA


Traceback (most recent call last):
  File "http3/httpx/dispatch/http11.py", line 53, in send
    http_version, status_code, headers = await self._receive_response(timeout)
  File "http3/httpx/dispatch/http11.py", line 133, in _receive_response
    event = await self._receive_event(timeout)
  File "http3/httpx/dispatch/http11.py", line 174, in _receive_event
    self.READ_NUM_BYTES, timeout, flag=self.timeout_flag
  File "http3/httpx/concurrency/asyncio.py", line 92, in read
    raise ReadTimeout() from None
http3.httpx.exceptions.ReadTimeout

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "test_proto.py", line 16, in <module>
    asyncio.get_event_loop().run_until_complete(test())
  File "/lib/python3.7/asyncio/base_events.py", line 584, in run_until_complete
    return future.result()
  File "test_proto.py", line 11, in test
    resp = await client.post(url, data='debug', allow_redirects=True)
  File "http3/httpx/client.py", line 415, in post
    trust_env=trust_env,
  File "http3/httpx/client.py", line 566, in request
    trust_env=trust_env,
  File "http3/httpx/client.py", line 237, in send
    return await get_response(request)
  File "http3/httpx/middleware.py", line 72, in __call__
    return await self(next_request, get_response)
  File "http3/httpx/middleware.py", line 62, in __call__
    response = await get_response(request)
  File "http3/httpx/client.py", line 202, in get_response
    request, verify=verify, cert=cert, timeout=timeout
  File "http3/httpx/dispatch/connection_pool.py", line 126, in send
    raise exc
  File "http3/httpx/dispatch/connection_pool.py", line 121, in send
    request, verify=verify, cert=cert, timeout=timeout
  File "http3/httpx/dispatch/connection.py", line 65, in send
    response = await self.h11_connection.send(request, timeout=timeout)
  File "http3/httpx/dispatch/http11.py", line 53, in send
    http_version, status_code, headers = await self._receive_response(timeout)
  File "http3/httpx/concurrency/asyncio.py", line 285, in __aexit__
    await self.task
  File "http3/httpx/dispatch/http11.py", line 108, in _send_request_data
    await self._send_event(event, timeout)
  File "http3/httpx/dispatch/http11.py", line 123, in _send_event
    bytes_to_send = self.h11_state.send(event)
  File "site-packages/h11/_connection.py", line 469, in send
    data_list = self.send_with_data_passthrough(event)
  File "site-packages/h11/_connection.py", line 502, in send_with_data_passthrough
    writer(event, data_list.append)
  File "site-packages/h11/_writers.py", line 79, in __call__
    self.send_eom(event.headers, write)
  File "site-packages/h11/_writers.py", line 102, in send_eom
    raise LocalProtocolError("Too little data for declared Content-Length")
h11._util.LocalProtocolError: Too little data for declared Content-Length
```



Discussion:
Thanks for the POC! Looks like the debug logging is coming in handy already :) It looks like we're not persisting the data parameter on redirects, we should be doing that. 
@sethmlarson Should we? This is a case of 302 redirect from POST, and I believe in that case we redirect with GET (see [here](https://github.com/encode/httpx/blob/master/httpx/middleware.py#L113)) and strip out request content, just like browsers do (see [here](https://github.com/encode/httpx/blob/master/httpx/middleware.py#L157)). So what I think we’re missing is updating the content-length header to account for that change?

Resolving patch:
diff --git a/httpx/middleware.py b/httpx/middleware.py
--- a/httpx/middleware.py
+++ b/httpx/middleware.py
@@ -88,7 +88,7 @@ def build_redirect_request(
     ) -> AsyncRequest:
         method = self.redirect_method(request, response)
         url = self.redirect_url(request, response)
-        headers = self.redirect_headers(request, url)  # TODO: merge headers?
+        headers = self.redirect_headers(request, url, method)  # TODO: merge headers?
         content = self.redirect_content(request, method)
         cookies = Cookies(self.cookies)
         cookies.update(request.cookies)
@@ -138,15 +138,24 @@ def redirect_url(self, request: AsyncRequest, response: AsyncResponse) -> URL:
 
         return url
 
-    def redirect_headers(self, request: AsyncRequest, url: URL) -> Headers:
+    def redirect_headers(self, request: AsyncRequest, url: URL, method: str) -> Headers:
         """
-        Strip Authorization headers when responses are redirected away from
-        the origin.
+        Return the headers that should be used for the redirect request.
         """
         headers = Headers(request.headers)
+
         if url.origin != request.url.origin:
+            # Strip Authorization headers when responses are redirected away from
+            # the origin.
             del headers["Authorization"]
-            del headers["host"]
+            del headers["Host"]
+
+        if method != request.method and method == "GET":
+            # If we've switch to a 'GET' request, then strip any headers which
+            # are only relevant to the request body.
+            del headers["Content-Length"]
+            del headers["Transfer-Encoding"]
+
         return headers
 
     def redirect_content(self, request: AsyncRequest, method: str) -> bytes:


Test patch:
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -85,9 +85,15 @@ async def send(
                 codes.PERMANENT_REDIRECT, headers=headers, request=request
             )
 
+        elif request.url.path == "/redirect_no_body":
+            await request.read()
+            headers = {"location": "/redirect_body_target"}
+            return AsyncResponse(codes.SEE_OTHER, headers=headers, request=request)
+
         elif request.url.path == "/redirect_body_target":
             content = await request.read()
-            body = json.dumps({"body": content.decode()}).encode()
+            headers = dict(request.headers.items())
+            body = json.dumps({"body": content.decode(), "headers": headers}).encode()
             return AsyncResponse(codes.OK, content=body, request=request)
 
         elif request.url.path == "/cross_subdomain":
@@ -243,12 +249,29 @@ async def test_same_domain_redirect(backend):
 
 
 async def test_body_redirect(backend):
+    """
+    A 308 redirect should preserve the request body.
+    """
     client = AsyncClient(dispatch=MockDispatch(), backend=backend)
     url = "https://example.org/redirect_body"
     data = b"Example request body"
     response = await client.post(url, data=data)
     assert response.url == URL("https://example.org/redirect_body_target")
-    assert response.json() == {"body": "Example request body"}
+    assert response.json()["body"] == "Example request body"
+    assert "content-length" in response.json()["headers"]
+
+
+async def test_no_body_redirect(backend):
+    """
+    A 303 redirect should remove the request body.
+    """
+    client = AsyncClient(dispatch=MockDispatch(), backend=backend)
+    url = "https://example.org/redirect_no_body"
+    data = b"Example request body"
+    response = await client.post(url, data=data)
+    assert response.url == URL("https://example.org/redirect_body_target")
+    assert response.json()["body"] == ""
+    assert "content-length" not in response.json()["headers"]
 
 
 async def test_cannot_redirect_streaming_body(backend):