From 2840e4cfc85921dc4e558b7635b53aafd39aea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=2E=20K=C3=A4rkk=C3=A4inen?= Date: Sun, 1 Mar 2020 13:10:53 +0200 Subject: [PATCH] Everything but CustomServer OK. --- sanic/app.py | 4 + sanic/http.py | 376 +++++++++++++++++++--------------- sanic/server.py | 4 +- tests/test_request_timeout.py | 8 +- 4 files changed, 221 insertions(+), 171 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index aae379b3..da171867 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1028,6 +1028,8 @@ class Sanic: response = await self._run_response_middleware( request, response, request_name=name ) + except CancelledError: + raise except Exception: error_logger.exception( "Exception occurred in one of response " @@ -1043,6 +1045,8 @@ class Sanic: f"Invalid response type {response!r} (need HTTPResponse)" ) + except CancelledError: + raise except Exception as e: # -------------------------------------------- # # Response Generation Failed diff --git a/sanic/http.py b/sanic/http.py index 439ae52a..c899831a 100644 --- a/sanic/http.py +++ b/sanic/http.py @@ -29,6 +29,24 @@ class Stage(Enum): HTTP_CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n" class Http: + __slots__ = [ + "_send", + "_receive_more", + "recv_buffer", + "protocol", + "expecting_continue", + "stage", + "keep_alive", + "head_only", + "request", + "exception", + "url", + "request_chunked", + "request_bytes_left", + "response", + "response_func", + "response_bytes_left", + ] def __init__(self, protocol): self._send = protocol.send self._receive_more = protocol.receive_more @@ -40,8 +58,46 @@ class Http: self.head_only = None self.request = None self.exception = None + self.url = None - async def http1_receive_request(self): + async def http1(self): + """HTTP 1.1 connection handler""" + while True: # As long as connection stays keep-alive + try: + # Receive and handle a request + self.stage = Stage.REQUEST + self.response_func = self.http1_response_start + await self.http1_request_header() + await self.protocol.request_handler(self.request) + # Handler finished, response should've been sent + if self.stage is Stage.HANDLER: + raise ServerError("Handler produced no response") + if self.stage is Stage.RESPONSE: + await self.send(end_stream=True) + # Consume any remaining request body (TODO: or disconnect?) + if self.request_bytes_left or self.request_chunked: + logger.error(f"{self.request} body not consumed.") + async for _ in self: + pass + except CancelledError: + # Write an appropriate response before exiting + e = self.exception or ServiceUnavailable(f"Cancelled") + self.exception = None + self.keep_alive = False + await self.error_response(e) + except Exception as e: + # Write an error response + await self.error_response(e) + # Exit and disconnect if finished + if self.stage is not Stage.IDLE or not self.keep_alive: + break + # Wait for next request + if not self.recv_buffer: + await self._receive_more() + + async def http1_request_header(self): + """Receive and parse request header into self.request.""" + # Receive until full header is in buffer buf = self.recv_buffer pos = 0 while len(buf) < self.protocol.request_max_size: @@ -55,105 +111,195 @@ class Http: self.stage = Stage.REQUEST else: raise PayloadTooLarge("Payload Too Large") - - self.protocol._total_request_size = pos + 4 - + # Parse header content try: - reqline, *headers = buf[:pos].decode().split("\r\n") + reqline, *raw_headers = buf[:pos].decode().split("\r\n") method, self.url, protocol = reqline.split(" ") - if protocol not in ("HTTP/1.0", "HTTP/1.1"): + if protocol == "HTTP/1.1": + self.keep_alive = True + elif protocol == "HTTP/1.0": + self.keep_alive = False + else: raise Exception self.head_only = method.upper() == "HEAD" - headers = Header( - (name.lower(), value.lstrip()) - for name, value in (h.split(":", 1) for h in headers) - ) + body = False + headers = [] + for name, value in (h.split(":", 1) for h in raw_headers): + name, value = h = name.lower(), value.lstrip() + if name in ("content-length", "transfer-encoding"): + body = True + elif name == "connection": + self.keep_alive = value.lower() == "keep-alive" + headers.append(h) except: raise InvalidUsage("Bad Request") + # Prepare a Request object request = self.protocol.request_class( url_bytes=self.url.encode(), - headers=headers, + headers=Header(headers), version=protocol[-3:], method=method, transport=self.protocol.transport, app=self.protocol.app, ) - - # Prepare a request object from the header received request.stream = self self.protocol.state["requests_count"] += 1 - self.keep_alive = ( - protocol == "HTTP/1.1" - or headers.get("connection", "").lower() == "keep-alive" - ) # Prepare for request body - body = headers.get("transfer-encoding") == "chunked" or int( - headers.get("content-length", 0) - ) self.request_chunked = False self.request_bytes_left = 0 if body: + headers = request.headers expect = headers.get("expect") - if expect: + if expect is not None: if expect.lower() == "100-continue": self.expecting_continue = True else: - raise HeaderExpectationFailed( - f"Unknown Expect: {expect}") + raise HeaderExpectationFailed(f"Unknown Expect: {expect}") request.stream = self - if body is True: + if headers.get("transfer-encoding") == "chunked": self.request_chunked = True pos -= 2 # One CRLF stays in buffer else: - self.request_bytes_left = body + self.request_bytes_left = int(headers["content-length"]) # Remove header and its trailing CRLF del buf[: pos + 4] self.stage = Stage.HANDLER self.request = request - async def http1(self): - """HTTP 1.1 connection handler""" - while self.stage is Stage.IDLE and self.keep_alive: - try: - # Receive request header and call handler - await self.http1_receive_request() - await self.protocol.request_handler(self.request) - if self.stage is Stage.HANDLER: - raise ServerError("Handler produced no response") - # Finish sending a response (if no error) - if self.stage is Stage.RESPONSE: - await self.send(end_stream=True) - # Consume any remaining request body - if self.request_bytes_left or self.request_chunked: - logger.error(f"{self.request} body not consumed.") - async for _ in self: - pass - except CancelledError: - # Exit after trying to finish a response - self.keep_alive = False - if self.exception is None: - self.exception = ServiceUnavailable(f"Cancelled") - except Exception as e: - self.exception = e - if self.exception: - e, self.exception = self.exception, None - # Exception while idle? Probably best to close connection - if self.stage is Stage.IDLE: - return - # Request failure? Try to respond but then disconnect - if self.stage is Stage.REQUEST: - self.keep_alive = False - self.stage = Stage.HANDLER - # Return an error page if possible - if self.stage is Stage.HANDLER: - app = self.protocol.app - response = await app.handle_exception(self.request, e) - await self.respond(response).send(end_stream=True) + def http1_response_start(self, data, end_stream) -> bytes: + res = self.response + # Compatibility with simple response body + if not data and res.body: + data, end_stream = res.body, True + size = len(data) + status = res.status + headers = res.headers + if res.content_type and "content-type" not in headers: + headers["content-type"] = res.content_type + # Not Modified, Precondition Failed + if status in (304, 412): + headers = remove_entity_headers(headers) + if not has_message_body(status): + # Header-only response status + self.response_func = None + if ( + data + or not end_stream + or "content-length" in headers + or "transfer-encoding" in headers + ): + # TODO: This matches old Sanic operation but possibly + # an exception would be more appropriate? + data, size, end_stream = b"", 0, True + headers.pop("content-length", None) + headers.pop("transfer-encoding", None) + #raise ServerError( + # f"A {status} response may only have headers, no body." + #) + elif self.head_only and "content-length" in headers: + self.response_func = None + elif end_stream: + # Non-streaming response (all in one block) + headers["content-length"] = size + self.response_func = None + elif "content-length" in headers: + # Streaming response with size known in advance + self.response_bytes_left = int(headers["content-length"]) - size + self.response_func = self.http1_response_normal + else: + # Length not known, use chunked encoding + headers["transfer-encoding"] = "chunked" + data = b"%x\r\n%b\r\n" % (size, data) if size else None + self.response_func = self.http1_response_chunked + if self.head_only: + # Head request: don't send body + data = b"" + self.response_func = self.head_response_ignored + headers["connection"] = "keep-alive" if self.keep_alive else "close" + ret = format_http1_response(status, headers.items(), data) + # Send a 100-continue if expected and not Expectation Failed + if self.expecting_continue: + self.expecting_continue = False + if status != 417: + ret = HTTP_CONTINUE + ret + # Send response + self.log_response() + self.stage = Stage.IDLE if end_stream else Stage.RESPONSE + return ret + def head_response_ignored(self, data, end_stream): + """HEAD response: body data silently ignored.""" + if end_stream: + self.response_func = None + self.stage = Stage.IDLE + + def http1_response_chunked(self, data, end_stream) -> bytes: + """Format a part of response body in chunked encoding.""" + # Chunked encoding + size = len(data) + if end_stream: + self.response_func = None + self.stage = Stage.IDLE + if size: + return b"%x\r\n%b\r\n0\r\n\r\n" % (size, data) + return b"0\r\n\r\n" + return b"%x\r\n%b\r\n" % (size, data) if size else None + + def http1_response_normal(self, data: bytes, end_stream: bool) -> bytes: + """Format / keep track of non-chunked response.""" + self.response_bytes_left -= len(data) + if self.response_bytes_left <= 0: + if self.response_bytes_left < 0: + raise ServerError("Response was bigger than content-length") + self.response_func = None + self.stage = Stage.IDLE + elif end_stream: + raise ServerError("Response was smaller than content-length") + return data + + async def error_response(self, exception): + # Disconnect after an error if in any other state than handler + if self.stage is not Stage.HANDLER: + self.keep_alive = False + # Request failure? Respond but then disconnect + if self.stage is Stage.REQUEST: + self.stage = Stage.HANDLER + # From request and handler states we can respond, otherwise be silent + if self.stage is Stage.HANDLER: + app = self.protocol.app + response = await app.handle_exception(self.request, exception) + await self.respond(response).send(end_stream=True) + + def log_response(self): + """ + Helper method provided to enable the logging of responses in case if + the :attr:`HttpProtocol.access_log` is enabled. + + :param response: Response generated for the current request + + :type response: :class:`sanic.response.HTTPResponse` or + :class:`sanic.response.StreamingHTTPResponse` + + :return: None + """ + if self.protocol.access_log: + req, res = self.request, self.response + extra = { + "status": getattr(res, "status", 0), + "byte": getattr(self, "response_bytes_left", -1), + "host": "UNKNOWN", + "request": "nil", + } + if req is not None: + if req.ip: + extra["host"] = f"{req.ip}:{req.port}" + extra["request"] = f"{req.method} {req.url}" + access_logger.info("", extra=extra) # Request methods async def __aiter__(self): + """Async iterate over request body.""" while True: data = await self.read() if not data: @@ -161,6 +307,7 @@ class Http: yield data async def read(self): + """Read some bytes of request body.""" # Send a 100-continue if needed if self.expecting_continue: self.expecting_continue = False @@ -227,105 +374,8 @@ class Http: """ if data is None and end_stream is None: end_stream = True - data = self.data_to_send(data, end_stream) - if data is None: + data = data.encode() if hasattr(data, "encode") else data or b"" + data = self.response_func(data, end_stream) + if not data: return await self._send(data) - - def data_to_send(self, data, end_stream): - """Format output data bytes for given body data. - Headers are prepended to the first output block and then cleared. - :param data: str or bytes to be written - :return: bytes to send, or None if there is nothing to send - """ - data = data.encode() if hasattr(data, "encode") else data - size = len(data) if data is not None else 0 - - # Headers not yet sent? - if self.stage is Stage.HANDLER: - if self.response.body: - data = self.response.body + data if data else self.response.body - size = len(data) - r = self.response - status = r.status - headers = r.headers - if r.content_type and "content-type" not in headers: - headers["content-type"] = r.content_type - # Not Modified, Precondition Failed - if status in (304, 412): - headers = remove_entity_headers(headers) - if not has_message_body(status): - # Header-only response status - if ( - size > 0 - or not end_stream - or "content-length" in headers - or "transfer-encoding" in headers - ): - # TODO: This matches old Sanic operation but possibly - # an exception would be more appropriate? - data = None - size = 0 - end_stream = True - #raise ServerError( - # f"A {status} response may only have headers, no body." - #) - elif self.head_only and "content-length" in headers: - pass - elif end_stream: - # Non-streaming response (all in one block) - headers["content-length"] = size - elif "content-length" in headers: - # Streaming response with size known in advance - self.response_bytes_left = int(headers["content-length"]) - size - else: - # Length not known, use chunked encoding - headers["transfer-encoding"] = "chunked" - data = b"%x\r\n%b\r\n" % (size, data) if size else None - self.response_bytes_left = True - self.headers = None - if self.head_only: - data = None - self.response_bytes_left = None - if self.keep_alive: - headers["connection"] = "keep-alive" - headers["keep-alive"] = self.protocol.keep_alive_timeout - else: - headers["connection"] = "close" - ret = format_http1_response(status, headers.items(), data or b"") - # Send a 100-continue if expected and not Expectation Failed - if self.expecting_continue: - self.expecting_continue = False - if status != 417: - ret = HTTP_CONTINUE + ret - # Send response - self.stage = Stage.IDLE if end_stream else Stage.RESPONSE - return ret - - # HEAD request: don't send body - if self.head_only: - return None - - if self.stage is not Stage.RESPONSE: - if size: - raise RuntimeError("Cannot send data to a closed stream") - return - - # Chunked encoding - if self.response_bytes_left is True: - if end_stream: - self.response_bytes_left = None - self.stage = Stage.IDLE - if size: - return b"%x\r\n%b\r\n0\r\n\r\n" % (size, data) - return b"0\r\n\r\n" - return b"%x\r\n%b\r\n" % (size, data) if size else None - - # Normal encoding - else: - self.response_bytes_left -= size - if self.response_bytes_left <= 0: - if self.response_bytes_left < 0: - raise ServerError("Response was bigger than content-length") - self.stage = Stage.IDLE - return data if size else None diff --git a/sanic/server.py b/sanic/server.py index b7f9bada..646e5c09 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -26,7 +26,7 @@ from sanic.exceptions import ( ServiceUnavailable, ) from sanic.http import Http, Stage -from sanic.log import access_logger, logger +from sanic.log import logger from sanic.request import Request @@ -181,7 +181,7 @@ class HttpProtocol(asyncio.Protocol): stage in (Stage.HANDLER, Stage.RESPONSE, Stage.FAILED) and duration > self.response_timeout ): - self._http.exception = RequestTimeout("Response Timeout") + self._http.exception = ServiceUnavailable("Response Timeout") else: interval = min(self.keep_alive_timeout, self.request_timeout, self.response_timeout) / 2 self.loop.call_later(max(0.1, interval), self.check_timeouts) diff --git a/tests/test_request_timeout.py b/tests/test_request_timeout.py index f4eb8ee9..c518f087 100644 --- a/tests/test_request_timeout.py +++ b/tests/test_request_timeout.py @@ -79,12 +79,8 @@ class DelayableSanicTestClient(SanicTestClient): request_timeout_default_app = Sanic("test_request_timeout_default") request_no_timeout_app = Sanic("test_request_no_timeout") - -# Note: The delayed client pauses before making a request, so technically -# it is in keep alive duration. Earlier Sanic versions entered a new connection -# in request mode even if no bytes of request were received. -request_timeout_default_app.config.KEEP_ALIVE_TIMEOUT = 0.6 -request_no_timeout_app.config.KEEP_ALIVE_TIMEOUT = 0.6 +request_timeout_default_app.config.REQUEST_TIMEOUT = 0.6 +request_no_timeout_app.config.REQUEST_TIMEOUT = 0.6 @request_timeout_default_app.route("/1")