Repository: encode/uvicorn

Issue:
Support `Expect: 100-Continue`
* Deal with `Expect: 100-Continue` headers gracefully, by only sending if the request body is read prior to sending the response headers.


Discussion:

Resolving patch:
diff --git a/uvicorn/protocols/http/h11_impl.py b/uvicorn/protocols/http/h11_impl.py
--- a/uvicorn/protocols/http/h11_impl.py
+++ b/uvicorn/protocols/http/h11_impl.py
@@ -361,6 +361,7 @@ def __init__(
         # Connection state
         self.disconnected = False
         self.keep_alive = True
+        self.waiting_for_100_continue = conn.they_are_waiting_for_100_continue
 
         # Request state
         self.body = b""
@@ -433,6 +434,7 @@ async def send(self, message):
                 raise RuntimeError(msg % message_type)
 
             self.response_started = True
+            self.waiting_for_100_continue = False
 
             status_code = message["status"]
             headers = DEFAULT_HEADERS + message.get("headers", [])
@@ -465,7 +467,7 @@ async def send(self, message):
             more_body = message.get("more_body", False)
 
             # Write response body
-            if self.scope['method'] == "HEAD":
+            if self.scope["method"] == "HEAD":
                 event = h11.Data()
             else:
                 event = h11.Data(data=body)
@@ -492,6 +494,14 @@ async def send(self, message):
             self.on_response()
 
     async def receive(self):
+        if self.waiting_for_100_continue and not self.transport.is_closing():
+            event = h11.InformationalResponse(
+                status_code=100, headers=[], reason="Continue"
+            )
+            output = self.conn.send(event)
+            self.transport.write(output)
+            self.waiting_for_100_continue = False
+
         self.flow.resume_reading()
         await self.message_event.wait()
         self.message_event.clear()
diff --git a/uvicorn/protocols/http/httptools_impl.py b/uvicorn/protocols/http/httptools_impl.py
--- a/uvicorn/protocols/http/httptools_impl.py
+++ b/uvicorn/protocols/http/httptools_impl.py
@@ -144,6 +144,7 @@ def __init__(
         # Per-request state
         self.scope = None
         self.headers = None
+        self.expect_100_continue = False
         self.cycle = None
         self.message_event = asyncio.Event()
 
@@ -196,6 +197,7 @@ def data_received(self, data):
     def on_url(self, url):
         method = self.parser.get_method()
         parsed_url = httptools.parse_url(url)
+        self.expect_100_continue = False
         self.headers = []
         self.scope = {
             "type": "http",
@@ -211,7 +213,10 @@ def on_url(self, url):
         }
 
     def on_header(self, name: bytes, value: bytes):
-        self.headers.append((name.lower(), value))
+        name = name.lower()
+        if name == b"expect" and value.lower() == b"100-continue":
+            self.expect_100_continue = True
+        self.headers.append((name, value))
 
     def on_headers_complete(self):
         http_version = self.parser.get_http_version()
@@ -242,6 +247,7 @@ def on_headers_complete(self):
             flow=self.flow,
             logger=self.logger,
             message_event=self.message_event,
+            expect_100_continue=self.expect_100_continue,
             on_response=self.on_response_complete,
         )
         if existing_cycle is None or existing_cycle.response_complete:
@@ -337,7 +343,16 @@ def timeout_response_handler(self, task):
 
 
 class RequestResponseCycle:
-    def __init__(self, scope, transport, flow, logger, message_event, on_response):
+    def __init__(
+        self,
+        scope,
+        transport,
+        flow,
+        logger,
+        message_event,
+        expect_100_continue,
+        on_response,
+    ):
         self.scope = scope
         self.transport = transport
         self.flow = flow
@@ -348,6 +363,7 @@ def __init__(self, scope, transport, flow, logger, message_event, on_response):
         # Connection state
         self.disconnected = False
         self.keep_alive = True
+        self.waiting_for_100_continue = expect_100_continue
 
         # Request state
         self.body = b""
@@ -420,6 +436,7 @@ async def send(self, message):
                 raise RuntimeError(msg % message_type)
 
             self.response_started = True
+            self.waiting_for_100_continue = False
 
             status_code = message["status"]
             headers = message.get("headers", [])
@@ -467,7 +484,7 @@ async def send(self, message):
             more_body = message.get("more_body", False)
 
             # Write response body
-            if self.scope['method'] == "HEAD":
+            if self.scope["method"] == "HEAD":
                 self.expected_content_length = 0
             elif self.chunked_encoding:
                 content = [b"%x\r\n" % len(body), body, b"\r\n"]
@@ -497,6 +514,10 @@ async def send(self, message):
             raise RuntimeError(msg % message_type)
 
     async def receive(self):
+        if self.waiting_for_100_continue and not self.transport.is_closing():
+            self.transport.write(b"HTTP/1.1 100 Continue\r\n")
+            self.waiting_for_100_continue = False
+
         self.flow.resume_reading()
         await self.message_event.wait()
         self.message_event.clear()


Test patch:
diff --git a/tests/protocols/test_http.py b/tests/protocols/test_http.py
--- a/tests/protocols/test_http.py
+++ b/tests/protocols/test_http.py
@@ -625,3 +625,61 @@ def test_shutdown_during_idle(protocol_cls):
     protocol.shutdown()
     assert protocol.transport.buffer == b""
     assert protocol.transport.is_closing()
+
+
+@pytest.mark.parametrize("protocol_cls", [HttpToolsProtocol, H11Protocol])
+def test_100_continue_sent_when_body_consumed(protocol_cls):
+    class App:
+        def __init__(self, scope):
+            self.scope = scope
+
+        async def __call__(self, receive, send):
+            body = b""
+            more_body = True
+            while more_body:
+                message = await receive()
+                body += message.get("body", b"")
+                more_body = message.get("more_body", False)
+            response = Response(b"Body: " + body, media_type="text/plain")
+            await response(receive, send)
+
+    protocol = get_connected_protocol(App, protocol_cls)
+    EXPECT_100_REQUEST = b"\r\n".join(
+        [
+            b"POST / HTTP/1.1",
+            b"Host: example.org",
+            b"Expect: 100-continue",
+            b"Content-Type: application/json",
+            b"Content-Length: 18",
+            b"",
+            b'{"hello": "world"}',
+        ]
+    )
+    protocol.data_received(EXPECT_100_REQUEST)
+    protocol.loop.run_one()
+    assert b"HTTP/1.1 100 Continue" in protocol.transport.buffer
+    assert b"HTTP/1.1 200 OK" in protocol.transport.buffer
+    assert b'Body: {"hello": "world"}' in protocol.transport.buffer
+
+
+@pytest.mark.parametrize("protocol_cls", [HttpToolsProtocol, H11Protocol])
+def test_100_continue_not_sent_when_body_not_consumed(protocol_cls):
+    def app(scope):
+        return Response(b"", status_code=204)
+
+    protocol = get_connected_protocol(app, protocol_cls)
+    EXPECT_100_REQUEST = b"\r\n".join(
+        [
+            b"POST / HTTP/1.1",
+            b"Host: example.org",
+            b"Expect: 100-continue",
+            b"Content-Type: application/json",
+            b"Content-Length: 18",
+            b"",
+            b'{"hello": "world"}',
+        ]
+    )
+    protocol.data_received(EXPECT_100_REQUEST)
+    protocol.loop.run_one()
+    assert b"HTTP/1.1 100 Continue" not in protocol.transport.buffer
+    assert b"HTTP/1.1 204 No Content" in protocol.transport.buffer