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:
tomchristie: Okay, so forgetting for a moment about `1xx`, `204`, `304`, here's a sensible set of test cases that I can think of wrt. setting `Content-Length`...

* `Response()` - Regular empty response. `Content-Length: 0`  (`RedirectResponse` is a good real-world example of this case)
* `Response(content='...')` - Regular non-empty response. `Content-Length: x`
* `FileResponse(...)`  - File response, unknown length. `Transfer-Encoding: chunked`.
* `FileResponse(..., stat_result=...)`  - File response, known length. `Content-Length: x`
* `StreamingResponse(...)` - Streaming response, unknown length. `Transfer-Encoding: chunked`
* `StreamingResponse(..., headers={"Content-Length": "123"})` - Streaming response, known length. `Content-Length: x`

For `Transfer-Encoding: chunked` cases it's also potentially okay to just omit the header, since I *think* the ASGI server is responsible for adding that, but I've not fully verified that.
tomchristie: I've retitled this PR to be more representative of the change.
Kludex: Expressing the thoughts I've shared on Gitter here as well:

I need to be sure that the response class is not `StreamingResponse` or `FileResponse` before adding the `Content-Length: 0`, because those four cases are already being handled now (file response unknown size, file response known size, streaming response known size, and streaming response unknown size). 

I don't have (or I can't see) any attribute I can check to be sure it's not a "Transfer-Encoding: chunked" on the at the `init_headers()` point.

I've added a conditional on the `Response.init_headers()` to check if the response class was one of those two before adding the `content-length: 0` headers... Can we do better?
Kludex: Updated the solution to check if body is present on the response. If it's not, then it doesn't add the `Content-Lenght: 0` headers.
Kludex: For future reference, some discussions about this PR can be retrieved on Gitter: https://gitter.im/encode/community?at=61d6e204526fb77b3166a445
Kludex: I've removed the period at the end to squash the commit.

Thanks @tomchristie :)

Patch:
diff --git a/starlette/responses.py b/starlette/responses.py
index ffde4b9..da765cf 100644
--- a/starlette/responses.py
+++ b/starlette/responses.py
@@ -70,8 +70,8 @@ class Response:
             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
index 76e4353..bd4d8bc 100644
--- a/starlette/staticfiles.py
+++ b/starlette/staticfiles.py
@@ -100,7 +100,7 @@ class StaticFiles:
     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
index baba549..150fe47 100644
--- a/tests/test_responses.py
+++ b/tests/test_responses.py
@@ -13,6 +13,7 @@ from starlette.responses import (
     Response,
     StreamingResponse,
 )
+from starlette.testclient import TestClient
 
 
 def test_text_response(test_client_factory):
@@ -73,6 +74,20 @@ def test_quoting_redirect_response(test_client_factory):
     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"