Skip to content

Commit

Permalink
Merge branch 'main' into feat/optional-uvloop-use
Browse files Browse the repository at this point in the history
  • Loading branch information
prryplatypus committed Dec 9, 2021
2 parents 155f5fc + 96c027b commit 8a79dc0
Show file tree
Hide file tree
Showing 10 changed files with 405 additions and 53 deletions.
59 changes: 57 additions & 2 deletions sanic/app.py
Expand Up @@ -42,7 +42,7 @@
Union,
)
from urllib.parse import urlencode, urlunparse
from warnings import filterwarnings
from warnings import filterwarnings, warn

from sanic_routing.exceptions import ( # type: ignore
FinalizationError,
Expand All @@ -68,6 +68,7 @@
)
from sanic.handlers import ErrorHandler
from sanic.helpers import _default
from sanic.http import Stage
from sanic.log import LOGGING_CONFIG_DEFAULTS, Colors, error_logger, logger
from sanic.mixins.listeners import ListenerEvent
from sanic.models.futures import (
Expand Down Expand Up @@ -737,6 +738,50 @@ async def handle_exception(
context={"request": request, "exception": exception},
)

if (
request.stream is not None
and request.stream.stage is not Stage.HANDLER
):
error_logger.exception(exception, exc_info=True)
logger.error(
"The error response will not be sent to the client for "
f'the following exception:"{exception}". A previous response '
"has at least partially been sent."
)

# ----------------- deprecated -----------------
handler = self.error_handler._lookup(
exception, request.name if request else None
)
if handler:
warn(
"An error occurred while handling the request after at "
"least some part of the response was sent to the client. "
"Therefore, the response from your custom exception "
f"handler {handler.__name__} will not be sent to the "
"client. Beginning in v22.6, Sanic will stop executing "
"custom exception handlers in this scenario. Exception "
"handlers should only be used to generate the exception "
"responses. If you would like to perform any other "
"action on a raised exception, please consider using a "
"signal handler like "
'`@app.signal("http.lifecycle.exception")`\n'
"For further information, please see the docs: "
"https://sanicframework.org/en/guide/advanced/"
"signals.html",
DeprecationWarning,
)
try:
response = self.error_handler.response(request, exception)
if isawaitable(response):
response = await response
except BaseException as e:
logger.error("An error occurred in the exception handler.")
error_logger.exception(e)
# ----------------------------------------------

return

# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
Expand Down Expand Up @@ -766,6 +811,7 @@ async def handle_exception(
)
if response is not None:
try:
request.reset_response()
response = await request.respond(response)
except BaseException:
# Skip response middleware
Expand Down Expand Up @@ -875,7 +921,16 @@ async def handle_request(self, request: Request): # no cov
if isawaitable(response):
response = await response

if response is not None:
if request.responded:
if response is not None:
error_logger.error(
"The response object returned by the route handler "
"will not be sent to client. The request has already "
"been responded to."
)
if request.stream is not None:
response = request.stream.response
elif response is not None:
response = await request.respond(response)
elif not hasattr(handler, "is_websocket"):
response = request.stream.response # type: ignore
Expand Down
17 changes: 16 additions & 1 deletion sanic/asgi.py
Expand Up @@ -7,8 +7,10 @@

from sanic.compat import Header
from sanic.exceptions import ServerError
from sanic.http import Stage
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from sanic.server import ConnInfo
from sanic.server.websockets.connection import WebSocketConnection

Expand Down Expand Up @@ -83,6 +85,8 @@ class ASGIApp:
transport: MockTransport
lifespan: Lifespan
ws: Optional[WebSocketConnection]
stage: Stage
response: Optional[BaseHTTPResponse]

def __init__(self) -> None:
self.ws = None
Expand All @@ -95,6 +99,8 @@ async def create(
instance.sanic_app = sanic_app
instance.transport = MockTransport(scope, receive, send)
instance.transport.loop = sanic_app.loop
instance.stage = Stage.IDLE
instance.response = None
setattr(instance.transport, "add_task", sanic_app.loop.create_task)

headers = Header(
Expand Down Expand Up @@ -149,6 +155,8 @@ async def read(self) -> Optional[bytes]:
"""
Read and stream the body in chunks from an incoming ASGI message.
"""
if self.stage is Stage.IDLE:
self.stage = Stage.REQUEST
message = await self.transport.receive()
body = message.get("body", b"")
if not message.get("more_body", False):
Expand All @@ -163,11 +171,17 @@ async def __aiter__(self):
if data:
yield data

def respond(self, response):
def respond(self, response: BaseHTTPResponse):
if self.stage is not Stage.HANDLER:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")
if self.response is not None:
self.response.stream = None
response.stream, self.response = self, response
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
await self.transport.send(
Expand Down Expand Up @@ -195,6 +209,7 @@ async def __call__(self) -> None:
Handle the incoming request.
"""
try:
self.stage = Stage.HANDLER
await self.sanic_app.handle_request(self.request)
except Exception as e:
await self.sanic_app.handle_exception(self.request, e)
5 changes: 5 additions & 0 deletions sanic/http.py
Expand Up @@ -584,6 +584,11 @@ def respond(self, response: BaseHTTPResponse) -> BaseHTTPResponse:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")

# Disconnect any earlier but unused response object
if self.response is not None:
self.response.stream = None

# Connect and return the response
self.response, response.stream = response, self
return response

Expand Down
30 changes: 27 additions & 3 deletions sanic/request.py
Expand Up @@ -18,7 +18,6 @@
if TYPE_CHECKING:
from sanic.server import ConnInfo
from sanic.app import Sanic
from sanic.http import Http

import email.utils
import uuid
Expand All @@ -32,7 +31,7 @@

from sanic.compat import CancelledErrors, Header
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import InvalidUsage
from sanic.exceptions import InvalidUsage, ServerError
from sanic.headers import (
AcceptContainer,
Options,
Expand All @@ -42,6 +41,7 @@
parse_host,
parse_xforwarded,
)
from sanic.http import Http, Stage
from sanic.log import error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse
Expand Down Expand Up @@ -104,6 +104,7 @@ class Request:
"parsed_json",
"parsed_forwarded",
"raw_url",
"responded",
"request_middleware_started",
"route",
"stream",
Expand Down Expand Up @@ -155,6 +156,7 @@ def __init__(
self.stream: Optional[Http] = None
self.route: Optional[Route] = None
self._protocol = None
self.responded: bool = False

def __repr__(self):
class_name = self.__class__.__name__
Expand All @@ -164,6 +166,21 @@ def __repr__(self):
def generate_id(*_):
return uuid.uuid4()

def reset_response(self):
try:
if (
self.stream is not None
and self.stream.stage is not Stage.HANDLER
):
raise ServerError(
"Cannot reset response because previous response was sent."
)
self.stream.response.stream = None
self.stream.response = None
self.responded = False
except AttributeError:
pass

async def respond(
self,
response: Optional[BaseHTTPResponse] = None,
Expand All @@ -172,13 +189,19 @@ async def respond(
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
try:
if self.stream is not None and self.stream.response:
raise ServerError("Second respond call is not allowed.")
except AttributeError:
pass
# This logic of determining which response to use is subject to change
if response is None:
response = (self.stream and self.stream.response) or HTTPResponse(
response = HTTPResponse(
status=status,
headers=headers,
content_type=content_type,
)

# Connect the response
if isinstance(response, BaseHTTPResponse) and self.stream:
response = self.stream.respond(response)
Expand All @@ -193,6 +216,7 @@ async def respond(
error_logger.exception(
"Exception occurred in one of response middleware handlers"
)
self.responded = True
return response

async def receive_body(self):
Expand Down
20 changes: 17 additions & 3 deletions sanic/response.py
Expand Up @@ -3,6 +3,7 @@
from os import path
from pathlib import PurePath
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Expand All @@ -19,11 +20,15 @@
from sanic.compat import Header, open_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import has_message_body, remove_entity_headers
from sanic.http import Http
from sanic.models.protocol_types import HTMLProtocol, Range


if TYPE_CHECKING:
from sanic.asgi import ASGIApp

try:
from ujson import dumps as json_dumps
except ImportError:
Expand All @@ -45,7 +50,7 @@ def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Http = None
self.stream: Optional[Union[Http, ASGIApp]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
Expand Down Expand Up @@ -112,8 +117,17 @@ async def send(
"""
if data is None and end_stream is None:
end_stream = True
if end_stream and not data and self.stream.send is None:
return
if self.stream is None:
raise SanicException(
"No stream is connected to the response object instance."
)
if self.stream.send is None:
if end_stream and not data:
return
raise ServerError(
"Response stream was ended, no more response data is "
"allowed to be sent."
)
data = (
data.encode() # type: ignore
if hasattr(data, "encode")
Expand Down
16 changes: 15 additions & 1 deletion tests/conftest.py
Expand Up @@ -6,7 +6,8 @@
import sys
import uuid

from typing import Tuple
from logging import LogRecord
from typing import Callable, List, Tuple

import pytest

Expand Down Expand Up @@ -170,3 +171,16 @@ def run(app):
return caplog.record_tuples

return run


@pytest.fixture(scope="function")
def message_in_records():
def msg_in_log(records: List[LogRecord], msg: str):
error_captured = False
for record in records:
if msg in record.message:
error_captured = True
break
return error_captured

return msg_in_log

0 comments on commit 8a79dc0

Please sign in to comment.