Repository: encode/httpx

Issue:
Content-Length header is missing in POST/DELETE/PUT/PATCH requests without body
### Checklist

<!-- To help keep this issue tracker clean and focused, please make sure you tried *all* the following resources before submitting your question. -->

- [x] I searched the [HTTPX documentation](https://www.python-httpx.org) but couldn't find what I'm looking for.
- [x] I looked through similar issues on GitHub, but didn't find anything.
- [x] I looked up "How to do ... in HTTPX" on a search engine and didn't find any information.
- [x] I asked the [community chat](https://gitter.im/encode/community) for help ~~but didn't get an answer.~~ [and decided to create an issue. :)](https://gitter.im/encode/community?at=5ec95c46778fad0b13201d03)

### Environment
**httpx version**: `0.13.1`
**Python version**: `3.7.3`
**OS**: Windows 7

### Question
I am not sure how to classify this issue: it could be a bug, since it is an incompatible with `requests`, on the other hand, it could be feature request, if you consider it is not that important. So, I decided to go with question. :D

Was replacing `requests` with `httpx` in my small script and received an error from the server `httpx._exceptions.HTTPError: 411 Client Error: Length Required for url: ...` while doing a `POST`  request **without a body** (same behavior is applicable for `PUT` and `PATCH` requests too).

*Steps to reproduce:*
```python
import requests
import httpx

for client in (requests, httpx):
    for method in ("get", "head", "delete", "post", "put", "patch"):
        r = client.request(method, f"https://httpbin.org/headers")
        print(
            f"[{client.__name__}] method={method.upper()} "
            f'Content-Length={r.request.headers.get("Content-Length")}'
        )
```

`requests` adds `Content-Length` header in every possible http method, except `GET` and `HEAD`.
`httpx` does not add `Content-Length` header at all, **unless requests has a body**.

#866 added `Content-Length` to all methods, but since it is not needed in `GET` and `HEAD` methods, it was reverted.

I assume, it should be handled during the `Request` building.
`httpx` decides whether to add `Content-Length` depending on the steam type, which is handled in [_content_streams.py#L314](https://github.com/encode/httpx/blob/aa630d36c22642d7004a57abd222a14461ee4d77/httpx/_content_streams.py#L372).

### Thoughts on how to solve it
To be honest, I am not sure what is the proper way to deal with this. :-(

My first idea was to use `method` inside the [stream](https://github.com/encode/httpx/blob/aa630d36c22642d7004a57abd222a14461ee4d77/httpx/_content_streams.py#L49):
```python3
class ByteStream(ContentStream):
    def __init__(self, body: typing.Union[str, bytes], method: str) -> None:
        self.body = body.encode("utf-8") if isinstance(body, str) else body
        self.method = method

    def get_headers(self) -> typing.Dict[str, str]:        
        if self.method in ("GET", "HEAD"):
            return {}
        content_length = str(len(self.body)) if self.body else "0"
        return {"Content-Length": content_length}
```

But this approach is not great, since it requires to pass `method` from `Request` to `encode` and then to `ByteStream` inside it. Also, it is not clear what `method` should I use when building stream in [`read`](https://github.com/encode/httpx/blob/aa630d36c22642d7004a57abd222a14461ee4d77/httpx/_models.py#L656) methods.

Please, let me know what you think. Thanks!


Discussion:
I believe this is a bug as we're not following the [RFC](https://tools.ietf.org/html/rfc7230#section-3.3.2):

> A user agent SHOULD send a Content-Length in a request message when no Transfer-Encoding is sent and the request method defines a meaning for an enclosed payload body.  For example, a Content-Length header field is normally sent in a POST request even when the value is 0 (indicating an empty payload body).
Thanks!

As you mentioned I think it's best if content streams stay agnostic of the HTTP semantics here.

The `.get_headers()` method is called in `Request.prepare()`, so other options I can see as far as implementation goes…

- Pass `.get_headers(method=...)`; this would fix the "pass `method` down the content stream chain" item but streams would then have to be aware of HTTP semantics, not great.
- Make streams include `Content-Length` in all cases, but ignore that header in `Request.prepare()` if `method` does not accept a body.
I’d suggest auto including it in Request.prepare, if the method requires one, and none is currently present.
^Sounds good to me too — let the content stream not return a `Content-Length` if there's no body, and add a default `Content-Length: 0` in the request if it's required by the method.
We should cover `POST`, `PUT`, and `PATCH` here.

> [3.3.2](https://tools.ietf.org/html/rfc7230#section-3.3.2) -  "A user agent SHOULD send a Content-Length in a request message when no Transfer-Encoding is sent and the request method defines a meaning for an enclosed payload body"

And

> [4.3.5](https://tools.ietf.org/html/rfc7230#section-4.3.5) - "A payload within a DELETE request message has no defined semantics;"

Resolving patch:
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -616,6 +616,9 @@ def prepare(self) -> None:
         auto_headers: typing.List[typing.Tuple[bytes, bytes]] = []
 
         has_host = "host" in self.headers
+        has_content_length = (
+            "content-length" in self.headers or "transfer-encoding" in self.headers
+        )
         has_user_agent = "user-agent" in self.headers
         has_accept = "accept" in self.headers
         has_accept_encoding = "accept-encoding" in self.headers
@@ -626,6 +629,8 @@ def prepare(self) -> None:
             if url.userinfo:
                 url = url.copy_with(username=None, password=None)
             auto_headers.append((b"host", url.authority.encode("ascii")))
+        if not has_content_length and self.method in ("POST", "PUT", "PATCH"):
+            auto_headers.append((b"content-length", b"0"))
         if not has_user_agent:
             auto_headers.append((b"user-agent", USER_AGENT.encode("ascii")))
         if not has_accept:


Test patch:
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -37,6 +37,22 @@ def test_build_request(server):
     assert response.json()["Custom-header"] == "value"
 
 
+def test_build_post_request(server):
+    url = server.url.copy_with(path="/echo_headers")
+    headers = {"Custom-header": "value"}
+
+    with httpx.Client() as client:
+        request = client.build_request("POST", url)
+        request.headers.update(headers)
+        response = client.send(request)
+
+    assert response.status_code == 200
+    assert response.url == url
+
+    assert response.json()["Content-length"] == "0"
+    assert response.json()["Custom-header"] == "value"
+
+
 def test_post(server):
     with httpx.Client() as client:
         response = client.post(server.url, data=b"Hello, world!")