diff options
| author | Andrew Godwin <andrew@aeracode.org> | 2019-04-12 06:15:18 -0700 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2019-06-20 12:29:43 +0200 |
| commit | a415ce70bef6d91036b00dd2c8544aed7aeeaaed (patch) | |
| tree | 3583cef22e9b56d2ed52456ab586d9c47620bc51 /django/core | |
| parent | cce47ff65a4dd3786c049ec14ee889e128ca7de9 (diff) | |
Fixed #30451 -- Added ASGI handler and coroutine-safety.
This adds an ASGI handler, asgi.py file for the default project layout,
a few async utilities and adds async-safety to many parts of Django.
Diffstat (limited to 'django/core')
| -rw-r--r-- | django/core/asgi.py | 13 | ||||
| -rw-r--r-- | django/core/exceptions.py | 10 | ||||
| -rw-r--r-- | django/core/handlers/asgi.py | 297 | ||||
| -rw-r--r-- | django/core/signals.py | 2 |
4 files changed, 321 insertions, 1 deletions
diff --git a/django/core/asgi.py b/django/core/asgi.py new file mode 100644 index 0000000000..0d846ccd16 --- /dev/null +++ b/django/core/asgi.py @@ -0,0 +1,13 @@ +import django +from django.core.handlers.asgi import ASGIHandler + + +def get_asgi_application(): + """ + The public interface to Django's ASGI support. Return an ASGI 3 callable. + + Avoids making django.core.handlers.ASGIHandler a public API, in case the + internal implementation changes or moves in the future. + """ + django.setup(set_prefix=False) + return ASGIHandler() diff --git a/django/core/exceptions.py b/django/core/exceptions.py index 0e85397b9c..dc084b8692 100644 --- a/django/core/exceptions.py +++ b/django/core/exceptions.py @@ -63,6 +63,11 @@ class RequestDataTooBig(SuspiciousOperation): pass +class RequestAborted(Exception): + """The request was closed before it was completed, or timed out.""" + pass + + class PermissionDenied(Exception): """The user did not have permission to do that""" pass @@ -181,3 +186,8 @@ class ValidationError(Exception): class EmptyResultSet(Exception): """A database query predicate is impossible.""" pass + + +class SynchronousOnlyOperation(Exception): + """The user tried to call a sync-only function from an async context.""" + pass diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py new file mode 100644 index 0000000000..021298e55d --- /dev/null +++ b/django/core/handlers/asgi.py @@ -0,0 +1,297 @@ +import asyncio +import logging +import sys +import tempfile +import traceback +from io import BytesIO + +from asgiref.sync import sync_to_async + +from django.conf import settings +from django.core import signals +from django.core.exceptions import RequestAborted, RequestDataTooBig +from django.core.handlers import base +from django.http import ( + FileResponse, HttpRequest, HttpResponse, HttpResponseBadRequest, + HttpResponseServerError, QueryDict, parse_cookie, +) +from django.urls import set_script_prefix +from django.utils.functional import cached_property + +logger = logging.getLogger('django.request') + + +class ASGIRequest(HttpRequest): + """ + Custom request subclass that decodes from an ASGI-standard request dict + and wraps request body handling. + """ + # Number of seconds until a Request gives up on trying to read a request + # body and aborts. + body_receive_timeout = 60 + + def __init__(self, scope, body_file): + self.scope = scope + self._post_parse_error = False + self._read_started = False + self.resolver_match = None + self.script_name = self.scope.get('root_path', '') + if self.script_name and scope['path'].startswith(self.script_name): + # TODO: Better is-prefix checking, slash handling? + self.path_info = scope['path'][len(self.script_name):] + else: + self.path_info = scope['path'] + # The Django path is different from ASGI scope path args, it should + # combine with script name. + if self.script_name: + self.path = '%s/%s' % ( + self.script_name.rstrip('/'), + self.path_info.replace('/', '', 1), + ) + else: + self.path = scope['path'] + # HTTP basics. + self.method = self.scope['method'].upper() + # Ensure query string is encoded correctly. + query_string = self.scope.get('query_string', '') + if isinstance(query_string, bytes): + query_string = query_string.decode() + self.META = { + 'REQUEST_METHOD': self.method, + 'QUERY_STRING': query_string, + 'SCRIPT_NAME': self.script_name, + 'PATH_INFO': self.path_info, + # WSGI-expecting code will need these for a while + 'wsgi.multithread': True, + 'wsgi.multiprocess': True, + } + if self.scope.get('client'): + self.META['REMOTE_ADDR'] = self.scope['client'][0] + self.META['REMOTE_HOST'] = self.META['REMOTE_ADDR'] + self.META['REMOTE_PORT'] = self.scope['client'][1] + if self.scope.get('server'): + self.META['SERVER_NAME'] = self.scope['server'][0] + self.META['SERVER_PORT'] = str(self.scope['server'][1]) + else: + self.META['SERVER_NAME'] = 'unknown' + self.META['SERVER_PORT'] = '0' + # Headers go into META. + for name, value in self.scope.get('headers', []): + name = name.decode('latin1') + if name == 'content-length': + corrected_name = 'CONTENT_LENGTH' + elif name == 'content-type': + corrected_name = 'CONTENT_TYPE' + else: + corrected_name = 'HTTP_%s' % name.upper().replace('-', '_') + # HTTP/2 say only ASCII chars are allowed in headers, but decode + # latin1 just in case. + value = value.decode('latin1') + if corrected_name in self.META: + value = self.META[corrected_name] + ',' + value + self.META[corrected_name] = value + # Pull out request encoding, if provided. + self._set_content_type_params(self.META) + # Directly assign the body file to be our stream. + self._stream = body_file + # Other bits. + self.resolver_match = None + + @cached_property + def GET(self): + return QueryDict(self.META['QUERY_STRING']) + + def _get_scheme(self): + return self.scope.get('scheme') or super()._get_scheme() + + def _get_post(self): + if not hasattr(self, '_post'): + self._load_post_and_files() + return self._post + + def _set_post(self, post): + self._post = post + + def _get_files(self): + if not hasattr(self, '_files'): + self._load_post_and_files() + return self._files + + POST = property(_get_post, _set_post) + FILES = property(_get_files) + + @cached_property + def COOKIES(self): + return parse_cookie(self.META.get('HTTP_COOKIE', '')) + + +class ASGIHandler(base.BaseHandler): + """Handler for ASGI requests.""" + request_class = ASGIRequest + # Size to chunk response bodies into for multiple response messages. + chunk_size = 2 ** 16 + + def __init__(self): + super(ASGIHandler, self).__init__() + self.load_middleware() + + async def __call__(self, scope, receive, send): + """ + Async entrypoint - parses the request and hands off to get_response. + """ + # Serve only HTTP connections. + # FIXME: Allow to override this. + if scope['type'] != 'http': + raise ValueError( + 'Django can only handle ASGI/HTTP connections, not %s' + % scope['type'] + ) + # Receive the HTTP request body as a stream object. + try: + body_file = await self.read_body(receive) + except RequestAborted: + return + # Request is complete and can be served. + set_script_prefix(self.get_script_prefix(scope)) + await sync_to_async(signals.request_started.send)(sender=self.__class__, scope=scope) + # Get the request and check for basic issues. + request, error_response = self.create_request(scope, body_file) + if request is None: + await self.send_response(error_response, send) + return + # Get the response, using a threadpool via sync_to_async, if needed. + if asyncio.iscoroutinefunction(self.get_response): + response = await self.get_response(request) + else: + # If get_response is synchronous, run it non-blocking. + response = await sync_to_async(self.get_response)(request) + response._handler_class = self.__class__ + # Increase chunk size on file responses (ASGI servers handles low-level + # chunking). + if isinstance(response, FileResponse): + response.block_size = self.chunk_size + # Send the response. + await self.send_response(response, send) + + async def read_body(self, receive): + """Reads a HTTP body from an ASGI connection.""" + # Use the tempfile that auto rolls-over to a disk file as it fills up, + # if a maximum in-memory size is set. Otherwise use a BytesIO object. + if settings.FILE_UPLOAD_MAX_MEMORY_SIZE is None: + body_file = BytesIO() + else: + body_file = tempfile.SpooledTemporaryFile(max_size=settings.FILE_UPLOAD_MAX_MEMORY_SIZE, mode='w+b') + while True: + message = await receive() + if message['type'] == 'http.disconnect': + # Early client disconnect. + raise RequestAborted() + # Add a body chunk from the message, if provided. + if 'body' in message: + body_file.write(message['body']) + # Quit out if that's the end. + if not message.get('more_body', False): + break + body_file.seek(0) + return body_file + + def create_request(self, scope, body_file): + """ + Create the Request object and returns either (request, None) or + (None, response) if there is an error response. + """ + try: + return self.request_class(scope, body_file), None + except UnicodeDecodeError: + logger.warning( + 'Bad Request (UnicodeDecodeError)', + exc_info=sys.exc_info(), + extra={'status_code': 400}, + ) + return None, HttpResponseBadRequest() + except RequestDataTooBig: + return None, HttpResponse('413 Payload too large', status=413) + + def handle_uncaught_exception(self, request, resolver, exc_info): + """Last-chance handler for exceptions.""" + # There's no WSGI server to catch the exception further up + # if this fails, so translate it into a plain text response. + try: + return super().handle_uncaught_exception(request, resolver, exc_info) + except Exception: + return HttpResponseServerError( + traceback.format_exc() if settings.DEBUG else 'Internal Server Error', + content_type='text/plain', + ) + + async def send_response(self, response, send): + """Encode and send a response out over ASGI.""" + # Collect cookies into headers. Have to preserve header case as there + # are some non-RFC compliant clients that require e.g. Content-Type. + response_headers = [] + for header, value in response.items(): + if isinstance(header, str): + header = header.encode('ascii') + if isinstance(value, str): + value = value.encode('latin1') + response_headers.append((bytes(header), bytes(value))) + for c in response.cookies.values(): + response_headers.append( + (b'Set-Cookie', c.output(header='').encode('ascii').strip()) + ) + # Initial response message. + await send({ + 'type': 'http.response.start', + 'status': response.status_code, + 'headers': response_headers, + }) + # Streaming responses need to be pinned to their iterator. + if response.streaming: + # Access `__iter__` and not `streaming_content` directly in case + # it has been overridden in a subclass. + for part in response: + for chunk, _ in self.chunk_bytes(part): + await send({ + 'type': 'http.response.body', + 'body': chunk, + # Ignore "more" as there may be more parts; instead, + # use an empty final closing message with False. + 'more_body': True, + }) + # Final closing message. + await send({'type': 'http.response.body'}) + # Other responses just need chunking. + else: + # Yield chunks of response. + for chunk, last in self.chunk_bytes(response.content): + await send({ + 'type': 'http.response.body', + 'body': chunk, + 'more_body': not last, + }) + response.close() + + @classmethod + def chunk_bytes(cls, data): + """ + Chunks some data up so it can be sent in reasonable size messages. + Yields (chunk, last_chunk) tuples. + """ + position = 0 + if not data: + yield data, True + return + while position < len(data): + yield ( + data[position:position + cls.chunk_size], + (position + cls.chunk_size) >= len(data), + ) + position += cls.chunk_size + + def get_script_prefix(self, scope): + """ + Return the script prefix to use from either the scope or a setting. + """ + if settings.FORCE_SCRIPT_NAME: + return settings.FORCE_SCRIPT_NAME + return scope.get('root_path', '') or '' diff --git a/django/core/signals.py b/django/core/signals.py index 5d9618dd0c..c4288edeb5 100644 --- a/django/core/signals.py +++ b/django/core/signals.py @@ -1,6 +1,6 @@ from django.dispatch import Signal -request_started = Signal(providing_args=["environ"]) +request_started = Signal(providing_args=["environ", "scope"]) request_finished = Signal() got_request_exception = Signal(providing_args=["request"]) setting_changed = Signal(providing_args=["setting", "value", "enter"]) |
