Skip to content

Instantly share code, notes, and snippets.

@kgriffs
Last active April 30, 2023 11:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kgriffs/023dcdc39c07c0ec0c749d0ddf29c4da to your computer and use it in GitHub Desktop.
Save kgriffs/023dcdc39c07c0ec0c749d0ddf29c4da to your computer and use it in GitHub Desktop.
Falcon ASGI+WebSocket Interface Proposal (By Example)
# See also:
#
# * https://asgi.readthedocs.io/en/latest/specs/www.html#websocket
# * https://developer.mozilla.org/en-US/docs/Web/API/Websockets_API
#
import falcon.asgi
import falcon.media
class SomeResource:
# Get a paginated list of events via a regular HTTP request.
#
# For small-scale, all-in-one apps, it may make sense to support
# both a regular HTTP interface and one based on WebSocket
# side-by-side in the same deployment. However, these two
# interaction models have very different performance characteristics,
# and so larger scale-out deployments may wish to specifically
# designate instance groups for one type of traffic vs. the
# other (although the actual applications may still be capable
# of handling both modes).
#
async def on_get(self, req: Request, account_id: str):
pass
# Push event stream to client. Note that the framework will pass
# parameters defined in the URI template as with HTTP method
# responders.
async def on_websocket(self, req: Request, ws: WebSocket, account_id: str):
# The HTTP request used to initiate the WebSocket handshake can be
# examined as needed.
some_header_value = req.get_header('Some-Header')
# Reject it?
if some_condition:
# If close() is called before accept() the code kwarg is
# ignored, if present, and the server returns a 403
# HTTP response without upgrading the connection.
await ws.close()
return
# Examine subprotocols advertised by the client. Here let's just
# assume we only support wamp, so if the client doesn't advertise
# it we reject the connection.
if 'wamp' not in ws.subprotocols:
# If close() is not called explicitly, the framework will
# take care of it automatically with the default code (1000).
return
# If, after examining the connection info, you would like to accept
# it, simply call accept() as follows:
try:
await ws.accept(subprotocol='wamp')
except WebSocketDisconnected:
return
# Simply start sending messages to the client if this is an event
# feed endpoint.
while True:
try:
event = await my_next_event()
# Send an instance of str as a WebSocket TEXT (0x01) payload
await ws.send_text(event)
# Send an instance of bytes, bytearray, or memoryview as a
# WebSocket BINARY (0x02) payload.
await ws.send_data(event)
# Or if you want it to be serialized to JSON (by default; can
# be customized via app.ws_options.media_handlers):
await ws.send_media(event) # Defaults to WebSocketPayloadType.TEXT
except WebSocketDisconnected:
# Do any necessary cleanup, then bail out
return
# ...or loop like this to implement a simple request-response protocol
while True:
try:
# Use this if you expect a WebSocket TEXT (0x01) payload,
# decoded from UTF-8 to a Unicode string.
payload_str = await ws.receive_text()
# Or if you are expecting a WebSocket BINARY (0x02) payload,
# in which case you will end up with a byte string result:
payload_bytes = await ws.receive_data()
# Or if you want to get a serialized media object (defaults to
# JSON deserialization of text payloads, and MessagePack
# deserialization for BINARY payloads, but this can be
# customized via app.ws_options.media_handlers).
media_object = await ws.receive_media()
except WebSocketDisconnected:
# Do any necessary cleanup, then bail out
return
except TypeError:
# The received message payload was not of the expected
# type (e.g., got BINARY when TEXT was expected).
pass
except json.JSONDecodeError:
# The default media deserializer uses the json standard
# library, so you might see this error raised as well.
pass
# At any time, you may decide to close the websocket. If the
# socket is already closed, this call does nothing (it will
# not raise an error.)
if we_are_so_done_with_this_conversation():
# https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
await ws.close(code=1000)
return
try:
# Here we are sending as a binary (0x02) payload type, which
# will go find the handler configured for that (defaults to
# MessagePack which assumes you've also installed that
# package, but this can be customized as mentioned above.')
await ws.send_media(
{'event': 'message'},
payload_type=WebSocketPayloadType.BINARY,
)
except WebSocketDisconnected:
# Do any necessary cleanup, then bail out. If ws.close() was
# not already called by the app, the framework will take
# care of it.
# NOTE: If you do not handle this exception, it will be
# bubbled up to a default error handler that simply
# logs the message as a warning and then closes the
# server side of the connection. This handler can be
# overwritten as with any other error handler for the app.
# NOTE: If the error handler accepts an 'ws' kwarg, the framework
# will pass in the falcon.asgi.WebSocket instance.
return
# ...or run a couple of different loops in parallel to support
# independent bidirectional message streams.
messages = collections.deque()
async def sink():
while True:
try:
message = await ws.receive_text()
except falcon.WebSocketDisconnected:
break
messages.append(message)
sink_task = falcon.create_task(sink())
while not sink_task.done():
while ws.ready and not messages and not sink_task.done():
await asyncio.sleep(0)
try:
await ws.send_text(messages.popleft())
except falcon.WebSocketDisconnected:
break
sink_task.cancel()
try:
await sink_task
except asyncio.CancelledError:
pass
class SomeMiddleware:
async def process_request_ws(self, req: Request):
# This will be called for the HTTP request that initiates the
# WebSocket handshake before routing.
pass
async def process_resource_ws(self, req: Request, resource, params):
# This will be called for the HTTP request that initiates the
# WebSocket handshake after routing (if a route matches the
# request).
pass
app = falcon.asgi.App(middleware=SomeMiddleware())
app.add_route('/{account_id}/messages', SomeResource())
# Let's say we want to use a faster JSON library. You could also use this
# pattern to add serialization support for custom types that aren't
# normally JSON-serializable out of the box.
class RapidJSONHandler(falcon.media.TextBaseHandlerWS):
def serialize(self, media: object) -> str:
return rapidjson.dumps(media, ensure_ascii=False)
# The raw TEXT payload will be passed as a Unicode string
def deserialize(self, payload: str) -> object:
return rapidjson.loads(payload)
# And/or for binary mode we want to use Protocol Buffers:
class ProtocolBuffersHandler(falcon.media.BinaryBaseHandlerWS):
def serialize(self, media: message_pb2.Message) -> Union[bytes, bytearray, memoryview]:
return media.SerializeToString()
# The raw BINARY payload will be passed as a byte string
def deserialize(self, payload: bytes) -> message_pb2.Message:
message = message_pb2.Message()
return message.ParseFromString(payload)
# Expected to (de)serialize from/to str
json_handler = RapidJSONHandler()
app.ws_options.media_handlers[falcon.WebSocketPayloadType.TEXT] = json_handler
# Expected to (de)serialize from/to bytes, bytearray, or memoryview
protobuf_handler = ProtocolBuffersHandler()
app.ws_options.media_handlers[falcon.WebSocketPayloadType.BINARY] = protobuf_handler
@kgriffs
Copy link
Author

kgriffs commented Mar 13, 2020

I updated the gist with some ideas re media handlers. Let me know what you think.

@onecrayon
Copy link

This all seems sensical to me. The only edge case I'm wondering about is if you're planning to offer access to HTTP headers (not possible to set these from Javascript when establishing a websocket connection, but they can be passed from other clients).

@kgriffs
Copy link
Author

kgriffs commented May 7, 2020

I updated the gist with one way we might expose the HTTP request from the handshake. Let me know what you think!

@onecrayon
Copy link

That makes sense to me. Only question: would ws.req expose anything else? If not, ws.get_header() might be sufficient.

@tyler46
Copy link

tyler46 commented May 8, 2020

hi :)

I have been following this gist since I really like the idea falcon + websocket. It might be some cases that a websocket connection includes also some query params. Having said that, perhaps it would be nice to include a subset of the existing attributes of http.req to ws.req. This subset could be of course very minimal compared to http.req.

Again it's just an idea.

@kgriffs
Copy link
Author

kgriffs commented May 8, 2020

What is interesting is that the ASGI connection scopes for HTTP vs. WebSocket are nearly identical, and so we could simply expose an instance of the falcon.asgi.Request class. The only thing we'd have to do is assume GET for the HTTP method since that field isn't explicit in the WebSocket scope. The WebSocket scope may also include a subprotocols field, but that would probably make more sense to attach as directly to the WebSocket class as a property (e.g., ws.subprotocols).

@kgriffs
Copy link
Author

kgriffs commented May 8, 2020

Question for everyone: Would it be better to have a separate middleware method (process_request_ws()) or simply also call process_request() for WebSocket handshakes and expose a Request property that the app can check as needed (e.g., req.is_ws)?

@CaselIT
Copy link

CaselIT commented May 8, 2020

I vote for process_request_ws since there are less chanches of breaking current middlewares.
At worst if a middleware works for websokets but has no explicit support for the new method a simple patch middleware.process_request_ws = middleware.process_request_ws can be done by an user

@onecrayon
Copy link

Agreed with process_request_ws being preferable. Particularly since these are potentially long-lived connections that only look like HTTP upon the initial connection, it's probably better to opt into middleware rather than having it implicitly supported.

@vytas7
Copy link

vytas7 commented May 9, 2020

@CaselIT I don't think there's massive risk of breaking current middlewares, because the absolute majority of those should be targeting the stable WSGI version of Falcon. I.e., they wouldn't be async defs 🙂 Good point about being able to just do process_request_ws = process_request in a pinch though.

Nevertheless I can see different middleware methods might make sense due to very different use cases. But I don't have a strong preference here; the same process_request for both could have advantages too.

so we could simply expose an instance of the falcon.asgi.Request class. The only thing we'd have to do is assume GET for the HTTP method since that field isn't explicit in the WebSocket scope.

👍

@vytas7
Copy link

vytas7 commented May 9, 2020

Just a side note if we are still planning to also support WebSocket on WSGI for uWSGI users (see a note here: falconry/falcon#321 (comment)). I doesn't look like uWSGI supports closing the socket in the middle of a handler in any meaningful way except from returning.

@tyler46
Copy link

tyler46 commented May 9, 2020

@CaselIT I don't think there's massive risk of breaking current middlewares, because the absolute majority of those should be targeting the stable WSGI version of Falcon. I.e., they wouldn't be async defs slightly_smiling_face Good point about being able to just do process_request_ws = process_request in a pinch though.

Nevertheless I can see different middleware methods might make sense due to very different use cases. But I don't have a strong preference here; the same process_request for both could have advantages too.

👍

@kgriffs
Copy link
Author

kgriffs commented May 13, 2020

Another way the handshake HTTP request might be presented would be to pass it as a separate argument, in a similar manner as the regular responder methods, e.g.:

async def on_websocket(self, req, ws):
    pass

@onecrayon
Copy link

Another way the handshake HTTP request might be presented would be to pass it as a separate argument, in a similar manner as the regular responder methods

Ooh, I actually like that a lot better. In particular, it makes more explicit that the request is a single-time thing that's separate from the websocket connection. Plus it's consistent with how the rest of the framework functions; request is a different object than the response (and ws fulfills a very similar role to resp since it handles passing data back to the client).

@vytas7
Copy link

vytas7 commented May 13, 2020

async def on_websocket(self, req, ws):
    pass

My vote goes for this one too ↖️ 💯

@tyler46
Copy link

tyler46 commented May 13, 2020

async def on_websocket(self, req, ws):
        pass

Yeap, this is even better 👍

@CaselIT
Copy link

CaselIT commented May 13, 2020

I also prefer this one.

Maybe to avoid possible confusion wrt the other on_ methods, we could start using pep484 in the examples.
Like

async def on_get(self, req: Request, res: Response):
    pass
async def on_websocket(self, req: Request, ws: WebSokect):
    pass

This make clear to the reader what the type are

@kgriffs
Copy link
Author

kgriffs commented May 14, 2020

Gist updated!

@kgriffs
Copy link
Author

kgriffs commented May 25, 2020

OK everyone, I have updated the gist again; please let me know what you think!

After careful consideration, I moved away from reusing the HTTP media handlers in favor of WebSocket-specific ones, since it is common to use the TEXT (0x01) WebSocket payload/frame type to serialize JSON, and ASGI requires passing a payload of type str to invoke the 0x01 frame type. The HTTP media handlers just always assume you want to go to/from bytes.

Also, I made send/receive methods more explicit according to the payload/frame type you want or expect. The WebSocket media handlers are keyed off the payload type, allowing you to have two different formats. I took this approach because the WebSocket protocol does not really have a standard way of negotiation Internet media types as with regular HTTP requests and responses. I figured that (1) most people are going to just assume JSON anyway and if they want something else, (2) they will want/need to customize it to their liking anyway.

@onecrayon
Copy link

That all makes sense to me, and the example code doesn't have any red flags standing out.

@tyler46
Copy link

tyler46 commented May 27, 2020

It's even better than previous proposal. I really like how text and binary get handled!

@kgriffs
Copy link
Author

kgriffs commented Jun 21, 2020

Here is an alternative proposal for the text/binary interface, modeled after the websockets interface:

# If str, sent as a text frame. If bytes, bytearray, or
#   memoryview, sent as a binary frame. Otherwise, raise
#   an error. To serialize from an object instead, use
#   send_media() instead.
await ws.send(message)

# Will first attempt to get text payload and decode from UTF-8, then
#   return as a str. If text frame not present, will decode from binary
#   frame and return as byte string. Use receive_media() instead to 
#   deserialize to an object.
message = await ws.receive()

Thoughts? Anyone have a strong opinion on the above approach vs. send_text(), send_binary() ? We could also support both, essentially making the above convenience functions that wrap the more explicit ones.

@CaselIT
Copy link

CaselIT commented Jun 22, 2020

I prefer the explicit one, but I think that we could support both quite easly

async def send(self, obj):
  if isinstance(obj, str):
    return self._sent_text(obj)
  if isinstance(obj, (byte, ...)):
    return self._send_binary(obj)
  raise TypeError(...)

@vytas7
Copy link

vytas7 commented Jun 22, 2020

No strong opinion here, but I also prefer the explicit send_s.
But I'm not opposed to providing convenience shims in the spirit of Response.body (text, but understands bytes too).

Talking of the response interface. Might it be worth renaming send_binary to send_data? OTOH "binary" better reflects the WebSocket binary payload type...

@kgriffs
Copy link
Author

kgriffs commented Jun 24, 2020

Might it be worth renaming send_binary to send_data?

I've been going back and forth on this in my mind...it would be more consistent with the rest of the framework to go with "data" and so perhaps that is better after all.

I prefer the explicit one

In my mind, one of the benefits of the explicit approach is that we can do more checking and raise an error sooner rather than later if someone accidentally uses the wrong data or frame type. If that is something we want to encourage, then adding the additional convenience methods may not be a good idea after all?

@kgriffs
Copy link
Author

kgriffs commented Jul 14, 2020

OK everyone, please take another look at the gist... I went ahead and modified the interface to be more consistent with other parts of the Falcon framework, namely using *_data instead of *_binary. Also, I'm thinking we will stick with explicit send/receive for now.

Feedback is welcome.

@kgriffs
Copy link
Author

kgriffs commented Jul 14, 2020

I should also mention that I am testing a prototype of this in my fork...hope to have something to show and tell soon!

@kgriffs
Copy link
Author

kgriffs commented Jul 20, 2020

@kgriffs
Copy link
Author

kgriffs commented Nov 24, 2020

PR has been merged! Next pre-release will include WS support. Please help test! falconry/falcon#1741

@kgriffs
Copy link
Author

kgriffs commented Dec 1, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment