Repository: encode/starlette

Issue:
Missing content-length header field in some responses
### Checklist

<!-- Please make sure you check all these items before submitting your bug report. -->

- [x] The bug is reproducible against the latest release and/or `master`.
- [x] There are no similar issues or pull requests to fix it yet.

### Describe the bug

The Content-Length header field is not set for cases where body is empty. In some cases this seems to not be according to the HTTP/1.1 spec and creates troubles when using for example traefik as a proxy in front of a starlette-based backend.

See this issue for details: https://github.com/traefik/traefik/issues/4456
Specifically, the final conclusion: https://github.com/traefik/traefik/issues/4456#issuecomment-593539169

### To reproduce

```python
>>> from starlette.responses import RedirectResponse
>>> RedirectResponse(url="/", status_code=302).headers
MutableHeaders({'location': '/'})
```
Header has not content-length field.

### Expected behavior

```python
>>> from starlette.responses import RedirectResponse
>>> RedirectResponse(url="/", status_code=302).headers
MutableHeaders({'content-length': '0', 'location': '/'})
```
I expect there to be a content-length field specifying zero length

### Actual behavior

```python
>>> from starlette.responses import RedirectResponse
>>> RedirectResponse(url="/", status_code=302).headers
MutableHeaders({'location': '/'})
```
Header has no content-length field.

### Debugging material

N/A

### Environment

- OS: Linux (WSL2)
- Python version: 3.8.2
- Starlette version: 0.13.6

### Additional context

This bug is probably also present for other type of responses (other status-codes). Last paragraph (in bold) from https://tools.ietf.org/html/rfc7230#section-3.3 is of interest:

> 3.3.  Message Body
> 
>    The message body (if any) of an HTTP message is used to carry the
>    payload body of that request or response.  The message body is
>    identical to the payload body unless a transfer coding has been
>    applied, as described in Section 3.3.1.
> 
>      message-body = *OCTET
> 
>    The rules for when a message body is allowed in a message differ for
>    requests and responses.
> 
>    The presence of a message body in a request is signaled by a
>    Content-Length or Transfer-Encoding header field.  Request message
>    framing is independent of method semantics, even if the method does
>    not define any use for a message body.
> 
>    The presence of a message body in a response depends on both the
>    request method to which it is responding and the response status code
>    (Section 3.1.2).  Responses to the HEAD request method (Section 4.3.2
>    of [RFC7231]) never include a message body because the associated
>    response header fields (e.g., Transfer-Encoding, Content-Length,
>    etc.), if present, indicate only what their values would have been if
>    the request method had been GET (Section 4.3.1 of [RFC7231]). 2xx
>    (Successful) responses to a CONNECT request method (Section 4.3.6 of
>    [RFC7231]) switch to tunnel mode instead of having a message body.
>    **All 1xx (Informational), 204 (No Content), and 304 (Not Modified)
>    responses do not include a message body.  All other responses do
>    include a message body, although the body might be of zero length.**


Discussion:
For anyone running into the same problem, I resorted to the following workaround using a middleware:

```python
@app.middleware("http")
async def content_length_header(request: Request, call_next: Callable) -> Response:
    response = await call_next(request)

    if 100 <= response.status_code < 200:
        return response

    if response.status_code == 204:
        return response

    if response.status_code == 304:
        return response

    if "content-length" not in response.headers:
        response.headers["content-length"] = "0"

    return response
```

On a side note: While using this workaround works well for the above problem, it made me run into the following issue in my test suite https://github.com/encode/starlette/issues/472

Resolving patch:
diff --git a/starlette/responses.py b/starlette/responses.py
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -70,8 +70,8 @@ def init_headers(self, headers: typing.Mapping[str, str] = None) -> None:
             populate_content_length = b"content-length" not in keys
             populate_content_type = b"content-type" not in keys
 
-        body = getattr(self, "body", b"")
-        if body and populate_content_length:
+        body = getattr(self, "body", None)
+        if body is not None and populate_content_length:
             content_length = str(len(body))
             raw_headers.append((b"content-length", content_length.encode("latin-1")))
 
diff --git a/starlette/staticfiles.py b/starlette/staticfiles.py
--- a/starlette/staticfiles.py
+++ b/starlette/staticfiles.py
@@ -100,7 +100,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
     def get_path(self, scope: Scope) -> str:
         """
         Given the ASGI scope, return the `path` string to serve up,
-        with OS specific path seperators, and any '..', '.' components removed.
+        with OS specific path separators, and any '..', '.' components removed.
         """
         return os.path.normpath(os.path.join(*scope["path"].split("/")))
 


Test patch:
diff --git a/tests/test_responses.py b/tests/test_responses.py
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -13,6 +13,7 @@
     Response,
     StreamingResponse,
 )
+from starlette.testclient import TestClient
 
 
 def test_text_response(test_client_factory):
@@ -73,6 +74,20 @@ async def app(scope, receive, send):
     assert response.url == "http://testserver/I%20%E2%99%A5%20Starlette/"
 
 
+def test_redirect_response_content_length_header(test_client_factory):
+    async def app(scope, receive, send):
+        if scope["path"] == "/":
+            response = Response("hello", media_type="text/plain")  # pragma: nocover
+        else:
+            response = RedirectResponse("/")
+        await response(scope, receive, send)
+
+    client: TestClient = test_client_factory(app)
+    response = client.request("GET", "/redirect", allow_redirects=False)
+    assert response.url == "http://testserver/redirect"
+    assert response.headers["content-length"] == "0"
+
+
 def test_streaming_response(test_client_factory):
     filled_by_bg_task = ""
 
@@ -309,3 +324,45 @@ def test_head_method(test_client_factory):
     client = test_client_factory(app)
     response = client.head("/")
     assert response.text == ""
+
+
+def test_empty_response(test_client_factory):
+    app = Response()
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert response.headers["content-length"] == "0"
+
+
+def test_non_empty_response(test_client_factory):
+    app = Response(content="hi")
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert response.headers["content-length"] == "2"
+
+
+def test_file_response_known_size(tmpdir, test_client_factory):
+    path = os.path.join(tmpdir, "xyz")
+    content = b"<file content>" * 1000
+    with open(path, "wb") as file:
+        file.write(content)
+
+    app = FileResponse(path=path, filename="example.png")
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert response.headers["content-length"] == str(len(content))
+
+
+def test_streaming_response_unknown_size(test_client_factory):
+    app = StreamingResponse(content=iter(["hello", "world"]))
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert "content-length" not in response.headers
+
+
+def test_streaming_response_known_size(test_client_factory):
+    app = StreamingResponse(
+        content=iter(["hello", "world"]), headers={"content-length": "10"}
+    )
+    client: TestClient = test_client_factory(app)
+    response = client.get("/")
+    assert response.headers["content-length"] == "10"