Manjusaka

Manjusaka

Several Complaints About Sanic

Some Complaints About Sanic#

I just had a discussion with Hongjie under the answer to Which Python libraries made you feel regretful for not discovering them earlier? about the pros and cons of Sanic.

It suddenly occurred to me that our company is one of the few in the country that uses Sanic in a formal production line. As a main promoter, I, as a subpar documentation engineer, feel it is necessary to talk about a series of pitfalls we encountered while using Sanic.

Main Text#

First, the slogan of the Sanic official site is a Flask Like web framework. This gives many people the illusion that the internal implementation of Sanic is almost identical to Flask, but is that really the case?

Let's first look at a Hello World example.

# Flask

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello World!'

if __name__ == '__main__':
    app.run()


# Sanic

from sanic import Sanic

app = Sanic()

@app.route("/")
async def hello_world(request):
    return "Hello World!"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

Did you notice any differences? Hmm? Is it because Sanic's View function has an extra parameter?

One of the most well-known features of Flask is its concept of Global Variables, such as the global g variable and the request variable, which is implemented through a mechanism similar to Thread.Local in werkzeug. During a request cycle, we can access the current request variable through from flask import request. We can also use this mechanism to attach some data for global use.

However, Sanic does not have this concept of Global Variables, meaning that if we want to use the request variable in our business logic, we need to continuously pass the request variable until the end of a request cycle.

This way of handling has its pros and cons, but our complaints are just beginning.

Pitfall 1: Extremely Inconvenient for Extensions#

For example, we now have a requirement to write a plugin for colleagues in other departments. In the plugin, we need to add some functionality to the original Request and Response classes. In Flask, we can do this easily.

from flask import Request, Response
from flask import Flask

class APIRequest(Request):
    pass
class APIResponse(Response):
    pass

class NewFlask(Flask):
    request_class = APIRequest
    response_class = APIResponse

In Flask, we can replace the original Request and Response classes by setting the two attributes request_class and response_class in the Flask class.

As shown in the code above, we can easily add some extra functionality to Request and Response.

But what about Sanic? It's quite painful.

class Sanic:

    def __init__(self, name=None, router=None, error_handler=None,
                 load_env=True, request_class=None,
                 strict_slashes=False, log_config=None,
                 configure_logging=True):

        # Get name from previous stack frame
        if name is None:
            frame_records = stack()[1]
            name = getmodulename(frame_records[1])

        # logging
        if configure_logging:
            logging.config.dictConfig(log_config or LOGGING_CONFIG_DEFAULTS)

        self.name = name
        self.router = router or Router()
        self.request_class = request_class
        self.error_handler = error_handler or ErrorHandler()
        self.config = Config(load_env=load_env)
        self.request_middleware = deque()
        self.response_middleware = deque()
        self.blueprints = {}
        self._blueprint_order = []
        self.configure_logging = configure_logging
        self.debug = None
        self.sock = None
        self.strict_slashes = strict_slashes
        self.listeners = defaultdict(list)
        self.is_running = False
        self.is_request_stream = False
        self.websocket_enabled = False
        self.websocket_tasks = set()

        # Register alternative method names
        self.go_fast = self.run

This is the initialization code for the Sanic class. First, in Sanic, we cannot easily replace Response. Secondly, by looking at its __init__ method, we can see that if we want to replace the default Request, we need to pass a parameter request_class during initialization. This is quite confusing; why should this be passed in?

Certainly, we can override the __init__ method of the Sanic class to modify its default parameters to solve this problem.

However, a new problem arises: I always think that when writing components, we should assume that all users of your component are, ummmm, not very smart.

Well, since we are providing a plugin, if a user inherits our customized Sanic class while not using super to call our modified __init__ method, then interesting issues will arise.

At the same time, the severe internal coupling of Sanic also makes it difficult for us to build plugins.

Pitfall 2: Severe Internal Coupling#

Now, when we write a plugin and want to perform some additional processing when generating a Response, in Flask, we can do this:

from flask import Flask

class NewFlask(Flask):
    def make_response(self):
        pass

We can directly override the make_response method in the Flask class to complete some additional operations when generating our Response.

This seemingly simple operation becomes quite troublesome in Sanic.

Sanic does not have independent methods for handling data flow at different stages of a request cycle like Flask does, such as dispatch_request, after_request, teardown_request, etc. The handling of Request and Response also has a clear boundary, and we can override as needed.

Sanic wraps the handling of Request data and Response data for an entire request cycle in a large handle_request method.


class Sanic:
    #.....
        async def handle_request(self, request, write_callback, stream_callback):
        """Take a request from the HTTP Server and return a response object
        to be sent back. The HTTP Server only expects a response object, so
        exception handling must be done here.

        :param request: HTTP Request object
        :param write_callback: Synchronous response function to be
            called with the response as the only argument
        :param stream_callback: Coroutine that handles streaming a
            StreamingHTTPResponse if produced by the handler.

        :return: Nothing
        """
        try:
            # -------------------------------------------- #
            # Request Middleware
            # -------------------------------------------- #

            request.app = self
            response = await self._run_request_middleware(request)
            # No middleware results
            if not response:
                # -------------------------------------------- #
                # Execute Handler
                # -------------------------------------------- #

                # Fetch handler from router
                handler, args, kwargs, uri = self.router.get(request)
                request.uri_template = uri
                if handler is None:
                    raise ServerError(
                        ("'None' was returned while requesting a "
                         "handler from the router"))

                # Run response handler
                response = handler(request, *args, **kwargs)
                if isawaitable(response):
                    response = await response
        except Exception as e:
            # -------------------------------------------- #
            # Response Generation Failed
            # -------------------------------------------- #

            try:
                response = self.error_handler.response(request, e)
                if isawaitable(response):
                    response = await response
            except Exception as e:
                if self.debug:
                    response = HTTPResponse(
                        "Error while handling error: {}\nStack: {}".format(
                            e, format_exc()))
                else:
                    response = HTTPResponse(
                        "An error occurred while handling an error")
        finally:
            # -------------------------------------------- #
            # Response Middleware
            # -------------------------------------------- #
            try:
                response = await self._run_response_middleware(request,
                                                               response)
            except BaseException:
                error_logger.exception(
                    'Exception occurred in one of response middleware handlers'
                )

        # pass the response to the correct callback
        if isinstance(response, StreamingHTTPResponse):
            await stream_callback(response)
        else:
            write_callback(response)

This creates a situation where if we want to perform additional operations on data for a specific stage, we inevitably have to override the large handle_request method. For example, as mentioned earlier, if we only want to perform additional operations when generating a Response, in Flask, we only need to override the corresponding make_response method, while in Sanic, we need to override the entire handle_request. This is truly a case of pulling one thread and affecting the whole.

Moreover, unlike Flask, Sanic does not achieve a separation between WSGI layer request handling and framework layer logic. This separation can sometimes bring us a lot of convenience.

For instance, I previously wrote a terrible article What You Don't Know About Flask Part 1: Exploring Routes, which mentioned a scenario where I encountered a strange requirement to support regular expressions in Flask, such as @app.route('/api/(.*?)').

This way, when the view function is called, it can receive values matched by the regular expression in the URL. However, Flask's routing does not support this method by default, so what should we do?

The solution is simple.


from flask import Flask
from werkzeug.routing import BaseConverter
class RegexConverter(BaseConverter):
    def __init__(self, map, *args):
        self.map = map
        self.regex = args[0]


app = Flask(__name__)
app.url_map.converters['regex'] = RegexConverter

After this setup, we can write code according to our previous requirements.

@app.route('/docs/model_utils/<regex(".*"):url>')
def hello(url=None):

    print(url)

As you can see, since Flask's WSGI layer processing is based on werkzeug, it means that sometimes when we deal with URL or other things involving the WSGI layer, we only need to override/use the relevant classes or functions provided by werkzeug. Meanwhile, the operation app.url_map.converters['regex'] = RegexConverter shows that url_map is a subclass of the Map class in werkzeug.routing, meaning that our operation is essentially an operation on werkzeug, unrelated to Flask's framework logic.

However, in Sanic, there is no such separation mechanism. For example, in the above scenario:

class Sanic:

    def __init__(self, name=None, router=None, error_handler=None,
                 load_env=True, request_class=None,
                 strict_slashes=False, log_config=None,
                 configure_logging=True):
        #....
        self.router = router or Router()
        #....

In Sanic, URL parsing is triggered by an instance of Router(). If we need to customize our own URL parsing, we need to replace self.router, which actually modifies Sanic itself, feeling somewhat inappropriate.

Additionally, in the Router class, if we need to customize our parsing, we need to override the following methods:


class Router:
    routes_static = None
    routes_dynamic = None
    routes_always_check = None
    parameter_pattern = re.compile(r'<(.+?)>')

The parameter_pattern attribute and several other parsing methods. The Router in Sanic does not have the feature of separating Route, Parser, and Formatter (which is the Converter) like the Router in werkzeug, meaning we can only reconstruct and add as needed, just like the examples given in the article.

This entire section is essentially complaining about the severe internal coupling of Sanic. If we want to implement some additional operations, it can be said that pulling one thread affects the whole.

Pitfall 3: Details and Other Issues#

There are several aspects to discuss in this section.

First, the libraries that Sanic depends on are, well, not very stable. For example, in October, a bug was triggered where the ujson it depends on would throw exceptions when serializing certain specific data. This issue was reported back in 2014 but has not been fixed until now, 2333333. Additionally, at that time, if users wanted to use built-in functions, they could not choose specific parsers. For more details, refer to the PR I submitted at that time.

Second, some implementations in Sanic are not rigorous. For example, this article has complained about A Daily Rant: A Small Issue with Sanic.

Third, Sanic currently does not support UWSGI, and when deployed with Gunicorn, it has implemented its own set of Gunicorn Workers. In our production environment, we encounter some mysterious bugs like unknown reason 504 errors, which we are still investigating (there are also reports that Sanic's server part does not strictly adhere to PEP333 WSGI protocol, = = I will check this out another day).

Conclusion#

The performance of Sanic is indeed impressive. During technical validation and testing, it can basically guarantee performance that is more than 1.5 times that of Flask under different business logic. However, based on current usage experience, Sanic still has a long way to go before it can be truly production-ready, whether in terms of internal architecture, surrounding ecosystem, or other aspects. Everyone can play around with it, but if you plan to use it in production, be prepared to face potential pitfalls.

Finally, I wish everyone a Happy New Year, Live Long And Prosper!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.