On ASGI's double-callable interface.
See original GitHub issueThis isn’t intended as an actionable point right now, rather just a point of conversation. I think it’s worth bringing up given that ASGI is pre-PEP at this point. (And that we might still just about be at a point where we could adapt things if we felt it worthwhile enough.)
I’m starting to wonder (again) if the double-callable structure if neccessarily a better trade-off than a single-callable. eg. the difference between:
ASGI as it stands:
class App:
def __init__(self, scope):
self.scope = scope
async def __call__(self, receive, send):
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
]
})
await send({
'type': 'http.response.body',
'body': b'Hello, world!',
})
An alternative interface:
async def app(scope, receive, send):
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
]
})
await send({
'type': 'http.response.body',
'body': b'Hello, world!',
})
The double-callable does allow classes to directly implement the ASGI interface, and for class based endpoint implementations it ends up being simpler.
However it’s more complex for function based endpoint implementations, for middleware implementation patterns, and for instantiate-to-configure application implementations.
In each of those cases you end up either with a closure around an inner function, or partially-bound functools.partial
. Both of those cases make for less easy-to-understand concepts and more awkward flows.
E.g. A typical middleware pattern with ASGI…
class Middleware:
def __init__(self, app, **middleware_config):
self.app = app
...
def __call__(self, scope):
return functools.partial(self.asgi, scope=scope)
async def asgi(self, receive, send, scope):
...
An alternative:
class Middleware:
def __init__(self, app, **middleware_config):
self.app = app
...
async def __call__(self, scope, receive, send):
...
Similarly with instantiate-to-configure ASGI applications, you currently end up having to return a partially bound method:
StaticFiles as ASGI…
class StaticFiles:
def __init__(self, directory):
self.directory = directory
...
def __call__(self, scope):
return functools.partial(self.asgi, scope=scope)
async def asgi(self, receive, send, scope):
...
Alternative:
class StaticFiles:
def __init__(self, directory):
self.directory = directory
...
async def __call__(self, scope, receive, send):
...
The flip side with a single-callable is that you can’t point directly at a class as an ASGI interface. For endpoint implementations, you end up needing to do something like this pattern instead…
class App:
def __init__(self, scope, receive, send):
self.scope = scope
self.receive = receive
self.send = send
async def dispatch(self):
await self.send({'type': 'http.response.start', ...})
await self.send({'type': 'http.response.body', ...})
@classmethod
async def asgi(cls, scope, receive, send):
instance = cls(scope, receive, send)
await instance.dispatch()
# app = App.asgi
This all starts matters a little in Starlette, where ASGI is the interface all the way through - you end up with a more complex flow, and less easy-to-understand primitives. It also ends up with more indirect tracebacks.
(I noticed this in particular when considering how to improve the stack traces for 500 responses. I was hoping to delimit each frame as “Inside the middleware stack”, “Inside the router”, “Inside the endpoint” - that’s easy to do if the traceback frames match up directly to the ASGI functions themselves, but it’s not do-able if the frames are from a function that’s been returned from another function.)
Anyways, not asking for any action on this neccessarily, but it’s something I’ve been thinking over that I wanted to make visible while ASGI is still pre-PEP.
Issue Analytics
- State:
- Created 5 years ago
- Comments:38 (35 by maintainers)
Summary of what I now think would be sufficient for a 3.0 release:
async def app(scope, receive, send)
, with backwards support for the double callable style.lifespan.startup.complete
andlifespan.shutdown.complete
messages with complementarylifespan.startup.failed
andlifespan.shutdown.failed
{"type": ..., "exc": ...}
messages.That’d enough that we don’t neccessarily need to include any handshaking or supported-protocol info beyond the currently existing spec.
Okay, had “Da-da-DAAAA” moment this morning.
Class instances can be awaitable.
This gives you:
await App(scope, receive, send)
, which will instantiate the class, and then rundispatch()
. So the single-callable style can still use an “instantiate then run” pattern, and can support uninstantiated classes as applications.Sure, so: