From 71cd53b64ec2d39a66feb8e256eeeea4ae332deb Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 20 Mar 2023 14:50:50 +0200 Subject: [PATCH] Standardize init of exceptions (#2545) --- sanic/__init__.py | 35 ++ sanic/exceptions.py | 489 +++++++++++++++++-- sanic/handlers/content_range.py | 2 +- sanic/server/protocols/websocket_protocol.py | 6 +- 4 files changed, 481 insertions(+), 51 deletions(-) diff --git a/sanic/__init__.py b/sanic/__init__.py index 64961760..67394ae6 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -2,6 +2,22 @@ from sanic.__version__ import __version__ from sanic.app import Sanic from sanic.blueprints import Blueprint from sanic.constants import HTTPMethod +from sanic.exceptions import ( + BadRequest, + ExpectationFailed, + FileNotFound, + Forbidden, + HeaderNotFound, + InternalServerError, + InvalidHeader, + MethodNotAllowed, + NotFound, + RangeNotSatisfiable, + SanicException, + ServerError, + ServiceUnavailable, + Unauthorized, +) from sanic.request import Request from sanic.response import ( HTTPResponse, @@ -9,6 +25,7 @@ from sanic.response import ( file, html, json, + raw, redirect, text, ) @@ -17,16 +34,34 @@ from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket __all__ = ( "__version__", + # Common objects "Sanic", "Blueprint", "HTTPMethod", "HTTPResponse", "Request", "Websocket", + # Common exceptions + "BadRequest", + "ExpectationFailed", + "FileNotFound", + "Forbidden", + "HeaderNotFound", + "InternalServerError", + "InvalidHeader", + "MethodNotAllowed", + "NotFound", + "RangeNotSatisfiable", + "SanicException", + "ServerError", + "ServiceUnavailable", + "Unauthorized", + # Common response methods "empty", "file", "html", "json", + "raw", "redirect", "text", ) diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 8baac0a4..51c0bb7f 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,5 +1,6 @@ -from asyncio import CancelledError -from typing import Any, Dict, Optional, Union +from asyncio import CancelledError, Protocol +from os import PathLike +from typing import Any, Dict, Optional, Sequence, Union from sanic.helpers import STATUS_CODES @@ -9,51 +10,158 @@ class RequestCancelled(CancelledError): class ServerKilled(Exception): - ... + """ + Exception Sanic server uses when killing a server process for something + unexpected happening. + """ class SanicException(Exception): + """ + Generic exception that will generate an HTTP response when raised + in the context of a request lifecycle. + + Usually it is best practice to use one of the more specific exceptions + than this generic. Even when trying to raise a 500, it is generally + preferrable to use :class:`.ServerError` + + .. code-block:: python + + raise SanicException( + "Something went wrong", + status_code=999, + context={ + "info": "Some additional details", + }, + headers={ + "X-Foo": "bar" + } + ) + + :param message: The message to be sent to the client. If ``None`` + then the appropriate HTTP response status message will be used + instead, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param status_code: The HTTP response code to send, if applicable. If + ``None``, then it will be 500, defaults to None + :type status_code: Optional[int], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional + """ + + status_code: int = 500 + quiet: Optional[bool] = False + headers: Dict[str, str] = {} message: str = "" def __init__( self, message: Optional[Union[str, bytes]] = None, status_code: Optional[int] = None, + *, quiet: Optional[bool] = None, context: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, ) -> None: self.context = context self.extra = extra + status_code = status_code or getattr( + self.__class__, "status_code", None + ) + quiet = quiet or getattr(self.__class__, "quiet", None) + headers = headers or getattr(self.__class__, "headers", {}) if message is None: if self.message: message = self.message - elif status_code is not None: + elif status_code: msg: bytes = STATUS_CODES.get(status_code, b"") message = msg.decode("utf8") super().__init__(message) - if status_code is not None: - self.status_code = status_code - - # quiet=None/False/True with None meaning choose by status - if quiet or quiet is None and status_code not in (None, 500): - self.quiet = True + self.status_code = status_code + self.quiet = quiet + self.headers = headers -class NotFound(SanicException): +class HTTPException(SanicException): + """ + A base class for other exceptions and should not be called directly. + """ + + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + + +class NotFound(HTTPException): """ **Status**: 404 Not Found + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Not Found' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 404 quiet = True -class BadRequest(SanicException): +class BadRequest(HTTPException): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 400 @@ -61,51 +169,133 @@ class BadRequest(SanicException): InvalidUsage = BadRequest +BadURL = BadRequest -class BadURL(BadRequest): - ... - - -class MethodNotAllowed(SanicException): +class MethodNotAllowed(HTTPException): """ **Status**: 405 Method Not Allowed + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Method Not Allowed' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param method: The HTTP method that was used, defaults to an empty string + :type method: Optional[str], optional + :param allowed_methods: The HTTP methods that can be used instead of the + one that was attempted + :type allowed_methods: Optional[Sequence[str]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 405 quiet = True - def __init__(self, message, method, allowed_methods): - super().__init__(message) - self.headers = {"Allow": ", ".join(allowed_methods)} + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + method: str = "", + allowed_methods: Optional[Sequence[str]] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + if allowed_methods: + self.headers = { + **self.headers, + "Allow": ", ".join(allowed_methods), + } + self.method = method + self.allowed_methods = allowed_methods MethodNotSupported = MethodNotAllowed -class ServerError(SanicException): +class ServerError(HTTPException): """ **Status**: 500 Internal Server Error + + A general server-side error has occurred. If no other HTTP exception is + appropriate, then this should be used + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Internal Server Error' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 500 -class ServiceUnavailable(SanicException): +InternalServerError = ServerError + + +class ServiceUnavailable(HTTPException): """ **Status**: 503 Service Unavailable The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state. + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 503 quiet = True -class URLBuildError(ServerError): +class URLBuildError(HTTPException): """ **Status**: 500 Internal Server Error + + An exception used by Sanic internals when unable to build a URL. """ status_code = 500 @@ -114,30 +304,77 @@ class URLBuildError(ServerError): class FileNotFound(NotFound): """ **Status**: 404 Not Found + + A specific form of :class:`.NotFound` that is specifically when looking + for a file on the file system at a known path. + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Not Found' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param path: The path, if any, to the file that could not + be found, defaults to None + :type path: Optional[PathLike], optional + :param relative_url: A relative URL of the file, defaults to None + :type relative_url: Optional[str], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ - def __init__(self, message, path, relative_url): - super().__init__(message) + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + path: Optional[PathLike] = None, + relative_url: Optional[str] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) self.path = path self.relative_url = relative_url -class RequestTimeout(SanicException): - """The Web server (running the Web site) thinks that there has been too +class RequestTimeout(HTTPException): + """ + The Web server (running the Web site) thinks that there has been too long an interval of time between 1) the establishment of an IP connection (socket) between the client and the server and 2) the receipt of any data on that socket, so the server has dropped the connection. The socket connection has actually been lost - the Web server has 'timed out' on that particular socket connection. + + This is an internal exception thrown by Sanic and should not be used + directly. """ status_code = 408 quiet = True -class PayloadTooLarge(SanicException): +class PayloadTooLarge(HTTPException): """ **Status**: 413 Payload Too Large + + This is an internal exception thrown by Sanic and should not be used + directly. """ status_code = 413 @@ -147,34 +384,126 @@ class PayloadTooLarge(SanicException): class HeaderNotFound(BadRequest): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ class InvalidHeader(BadRequest): """ **Status**: 400 Bad Request + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ -class RangeNotSatisfiable(SanicException): +class ContentRange(Protocol): + total: int + + +class RangeNotSatisfiable(HTTPException): """ **Status**: 416 Range Not Satisfiable + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Range Not Satisfiable' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param content_range: An object meeting the :class:`.ContentRange` protocol + that has a ``total`` property, defaults to None + :type content_range: Optional[ContentRange], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 416 quiet = True - def __init__(self, message, content_range): - super().__init__(message) - self.headers = {"Content-Range": f"bytes */{content_range.total}"} + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + content_range: Optional[ContentRange] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) + if content_range is not None: + self.headers = { + **self.headers, + "Content-Range": f"bytes */{content_range.total}", + } ContentRangeError = RangeNotSatisfiable -class ExpectationFailed(SanicException): +class ExpectationFailed(HTTPException): """ **Status**: 417 Expectation Failed + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Expectation Failed' will be sent, + defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 417 @@ -184,9 +513,25 @@ class ExpectationFailed(SanicException): HeaderExpectationFailed = ExpectationFailed -class Forbidden(SanicException): +class Forbidden(HTTPException): """ **Status**: 403 Forbidden + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Forbidden' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 403 @@ -202,20 +547,33 @@ class InvalidRangeType(RangeNotSatisfiable): quiet = True -class PyFileError(Exception): - def __init__(self, file): - super().__init__("could not execute config file %s", file) +class PyFileError(SanicException): + def __init__( + self, + file, + status_code: Optional[int] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + ): + super().__init__( + "could not execute config file %s" % file, + status_code=status_code, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) -class Unauthorized(SanicException): +class Unauthorized(HTTPException): """ **Status**: 401 Unauthorized - :param message: Message describing the exception. - :param status_code: HTTP Status code. - :param scheme: Name of the authentication scheme to be used. - - When present, kwargs is used to complete the WWW-Authentication header. + When present, additional keyword arguments may be used to complete + the WWW-Authentication header. Examples:: @@ -240,21 +598,58 @@ class Unauthorized(SanicException): raise Unauthorized("Auth required.", scheme="Bearer", realm="Restricted Area") + + :param message: The message to be sent to the client. If ``None`` + then the HTTP status 'Bad Request' will be sent, defaults to None + :type message: Optional[Union[str, bytes]], optional + :param scheme: Name of the authentication scheme to be used. + :type scheme: Optional[str], optional + :param quiet: When ``True``, the error traceback will be suppressed + from the logs, defaults to None + :type quiet: Optional[bool], optional + :param context: Additional mapping of key/value data that will be + sent to the client upon exception, defaults to None + :type context: Optional[Dict[str, Any]], optional + :param extra: Additional mapping of key/value data that will NOT be + sent to the client when in PRODUCTION mode, defaults to None + :type extra: Optional[Dict[str, Any]], optional + :param headers: Additional headers that should be sent with the HTTP + response, defaults to None + :type headers: Optional[Dict[str, Any]], optional """ status_code = 401 quiet = True - def __init__(self, message, status_code=None, scheme=None, **kwargs): - super().__init__(message, status_code) + def __init__( + self, + message: Optional[Union[str, bytes]] = None, + scheme: Optional[str] = None, + *, + quiet: Optional[bool] = None, + context: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + **challenges, + ): + super().__init__( + message, + quiet=quiet, + context=context, + extra=extra, + headers=headers, + ) # if auth-scheme is specified, set "WWW-Authenticate" header if scheme is not None: - values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()] + values = [ + '{!s}="{!s}"'.format(k, v) for k, v in challenges.items() + ] challenge = ", ".join(values) self.headers = { - "WWW-Authenticate": f"{scheme} {challenge}".rstrip() + **self.headers, + "WWW-Authenticate": f"{scheme} {challenge}".rstrip(), } diff --git a/sanic/handlers/content_range.py b/sanic/handlers/content_range.py index f3a23d1a..a95a677b 100644 --- a/sanic/handlers/content_range.py +++ b/sanic/handlers/content_range.py @@ -75,4 +75,4 @@ class ContentRangeHandler: } def __bool__(self): - return self.size > 0 + return hasattr(self, "size") and self.size > 0 diff --git a/sanic/server/protocols/websocket_protocol.py b/sanic/server/protocols/websocket_protocol.py index ce9c6244..52a33bf8 100644 --- a/sanic/server/protocols/websocket_protocol.py +++ b/sanic/server/protocols/websocket_protocol.py @@ -10,7 +10,7 @@ except ImportError: # websockets >= 11.0 from websockets.typing import Subprotocol -from sanic.exceptions import ServerError +from sanic.exceptions import SanicException from sanic.log import logger from sanic.server import HttpProtocol @@ -123,7 +123,7 @@ class WebSocketProtocol(HttpProtocol): "Failed to open a WebSocket connection.\n" "See server log for more information.\n" ) - raise ServerError(msg, status_code=500) + raise SanicException(msg, status_code=500) if 100 <= resp.status_code <= 299: first_line = ( f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n" @@ -138,7 +138,7 @@ class WebSocketProtocol(HttpProtocol): rbody += b"\r\n\r\n" await super().send(rbody) else: - raise ServerError(resp.body, resp.status_code) + raise SanicException(resp.body, resp.status_code) self.websocket = WebsocketImplProtocol( ws_proto, ping_interval=self.websocket_ping_interval,