Repository: sanic-org/sanic

Issue:
Response streaming produces [ERROR] Invalid response type None (need HTTPResponse)
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

The "response streaming" [feature of Sanic](https://sanic.dev/en/guide/advanced/streaming.html#response-streaming) produces error messages when running from Uvicorn.

When accessing a page using the `await request.respond()` API, it produces error messages after each request.

```
[2024-01-31 19:37:14 +0000] [694830] [INFO] 
  ┌─────────────────────────────────────────────────────────────────────────────────┐
  │                                  Sanic v23.6.0                                  │
  │                                                                                 │
  ├───────────────────────┬─────────────────────────────────────────────────────────┤
  │                       │     mode: production, ASGI                              │
  │     ▄███ █████ ██     │   server: ASGI                                          │
  │    ██                 │   python: 3.11.6                                        │
  │     ▀███████ ███▄     │ platform: Linux-5.15.0-1048-aws-x86_64-with-glibc2.31   │
  │                 ██    │ packages: sanic-routing==23.12.0, sanic-testing==23.6.0 │
  │    ████ ████████▀     │                                                         │
  │                       │                                                         │
  │ Build Fast. Run Fast. │                                                         │
  └───────────────────────┴─────────────────────────────────────────────────────────┘

INFO:     Application startup complete.
INFO:     127.0.0.1:42186 - "GET / HTTP/1.1" 200 OK
[2024-01-31 19:38:19 +0000] [694830] [ERROR] Invalid response type None (need HTTPResponse)
Traceback (most recent call last):
  File "handle_request", line 144, in handle_request
    "_inspector",
            ^^^^^
sanic.exceptions.ServerError: Invalid response type None (need HTTPResponse)
[2024-01-31 19:38:19 +0000] [694830] [ERROR] The error response will not be sent to the client for the following exception:"Invalid response type None (need HTTPResponse)". A previous response has at least partially been sent.
```

### Code snippet

```python

from sanic import Sanic

app = Sanic("my-hello-world-app")


@app.route("/")
async def test(request):
    response = await request.respond(content_type="text/plain")
    await response.send("hello world")
    await response.eof()


if __name__ == "__main__":
    app.run()
```

### Expected Behavior

Sanic should not produce error messages when using the response streaming API.

### How do you run Sanic?

ASGI

### Operating System

Linux

### Sanic Version

23.6.0

### Additional context

Possibly related to #2572, but it seems like a different issue. I can reproduce this without using WebSockets or SSE.


Discussion:

Resolving patch:
diff --git a/sanic/asgi.py b/sanic/asgi.py
--- a/sanic/asgi.py
+++ b/sanic/asgi.py
@@ -219,19 +219,26 @@ def respond(self, response: BaseHTTPResponse):
         return response
 
     async def send(self, data, end_stream):
-        self.stage = Stage.IDLE if end_stream else Stage.RESPONSE
-        if self.response:
-            response, self.response = self.response, None
+        if self.stage is Stage.IDLE:
+            if not end_stream or data:
+                raise RuntimeError(
+                    "There is no request to respond to, either the "
+                    "response has already been sent or the "
+                    "request has not been received yet."
+                )
+            return
+        if self.response and self.stage is Stage.HANDLER:
             await self.transport.send(
                 {
                     "type": "http.response.start",
-                    "status": response.status,
-                    "headers": response.processed_headers,
+                    "status": self.response.status,
+                    "headers": self.response.processed_headers,
                 }
             )
-            response_body = getattr(response, "body", None)
+            response_body = getattr(self.response, "body", None)
             if response_body:
                 data = response_body + data if data else response_body
+        self.stage = Stage.IDLE if end_stream else Stage.RESPONSE
         await self.transport.send(
             {
                 "type": "http.response.body",


Test patch:
diff --git a/tests/test_response.py b/tests/test_response.py
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -575,14 +575,20 @@ async def test(request: Request):
     assert "Content-Length" not in response.headers
 
 
-def test_two_respond_calls(app: Sanic):
+@pytest.mark.asyncio
+async def test_direct_response_stream_asgi(app: Sanic):
     @app.route("/")
-    async def handler(request: Request):
-        response = await request.respond()
+    async def test(request: Request):
+        response = await request.respond(content_type="text/csv")
         await response.send("foo,")
         await response.send("bar")
         await response.eof()
 
+    _, response = await app.asgi_client.get("/")
+    assert response.text == "foo,bar"
+    assert response.headers["Content-Type"] == "text/csv"
+    assert "Content-Length" not in response.headers
+
 
 def test_multiple_responses(
     app: Sanic,
@@ -684,7 +690,7 @@ async def handler6(request: Request):
         assert message_in_records(caplog.records, error_msg2)
 
 
-def send_response_after_eof_should_fail(
+def test_send_response_after_eof_should_fail(
     app: Sanic,
     caplog: LogCaptureFixture,
     message_in_records: Callable[[List[LogRecord], str], bool],
@@ -698,17 +704,48 @@ async def handler(request: Request):
 
     error_msg1 = (
         "The error response will not be sent to the client for the following "
-        'exception:"Second respond call is not allowed.". A previous '
+        'exception:"Response stream was ended, no more response '
+        'data is allowed to be sent.". A previous '
         "response has at least partially been sent."
     )
 
+    error_msg2 = "Response stream was ended, no more response data is allowed to be sent."
+
+    with caplog.at_level(ERROR):
+        _, response = app.test_client.get("/")
+        assert "foo, " in response.text
+        assert message_in_records(caplog.records, error_msg1)
+        assert message_in_records(caplog.records, error_msg2)
+
+
+@pytest.mark.asyncio
+async def test_send_response_after_eof_should_fail_asgi(
+    app: Sanic,
+    caplog: LogCaptureFixture,
+    message_in_records: Callable[[List[LogRecord], str], bool],
+):
+    @app.get("/")
+    async def handler(request: Request):
+        response = await request.respond()
+        await response.send("foo, ")
+        await response.eof()
+        await response.send("bar")
+
+    error_msg1 = (
+        "The error response will not be sent to the client for the "
+        'following exception:"There is no request to respond to, '
+        "either the response has already been sent or the request "
+        'has not been received yet.". A previous response has '
+        "at least partially been sent."
+    )
+
     error_msg2 = (
-        "Response stream was ended, no more "
-        "response data is allowed to be sent."
+        "There is no request to respond to, either the response has "
+        "already been sent or the request has not been received yet."
     )
 
     with caplog.at_level(ERROR):
-        _, response = app.test_client.get("/")
+        _, response = await app.asgi_client.get("/")
         assert "foo, " in response.text
         assert message_in_records(caplog.records, error_msg1)
         assert message_in_records(caplog.records, error_msg2)