Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save yoshuawuyts/50e2da1ced9ebe14cdcb0fad558dbc25 to your computer and use it in GitHub Desktop.
Save yoshuawuyts/50e2da1ced9ebe14cdcb0fad558dbc25 to your computer and use it in GitHub Desktop.

Layering RPC over duplex connections

This is just a thing I've been thinking about for a while, and I'm not sure what the best structure is here. I'd be keen to hear more about examples, literature, and experiences on how people have approached this. I feel it's an important thing to start thinking about early when talking about networking, especially with HTTP/2 & HTTP/3 on the horizon.

Background

Traditional HTTP servers can be expressed as asynchronous transform functions:

client -> req(data) -> server -> res(data) -> client

A request with data (and metadata) is received by a server. The server performs validation, authentication and probably some database operations, and then sends a response with data back to the client. A server usually handles lots of these requests in parallel.

But with duplex protocols such as HTTP/2 and WebSockets this is different. Namely: the connection is kept open for a long period of time, and multiple responses and requests can be sent over the same connection. Also notably: the server can initiate new responses without any triggers from the client. This means it's a conceptual departure from the RPC model.

Problem Definition

Now my question is: what's the right way to structure a server interface (with middleware) that naturally maps onto duplex connections. Especially considering the following facts:

  • An authentication handshake only needs to be performed once. After a user has been authenticated, all further messages on the channel are guaranteed to be between the same 2 entities. This means authentication middleware can be skipped at this point.
  • A server should be able to initiate multiple responses from a single request.
  • A server should never be able to initiate the first request. (I think at least; if a server initiates a request, I think it's fair to consider it a client at that point. The words "client" and "server" become a bit mixed in that case.)
  • A server does not need to respond to a request beyond acknowledging packets have been received.
  • These servers ought to have a fallback mode in which requests cannot be initiated from the server (e.g. HTTP/1 compat for HTTP/2 servers).

Examples

The way Node.js approaches this is by mapping a traditional RPC model on top of the duplex connection, and exposing the initialization capabilities on the server. This looks akin to this:

var http = require('http')

http.createHttp2Server((req, res) => {
  res.createPush((err, pushRes) => {
    if (err) throw err
    pushRes.end('pushing some data')

    res.end('connection done')
  })
}).listen(8080)

The idea is that there's an option to initialize multiple responses from a single request. res.end() is not required to be called either (if I recall correctly), as long as the timeout value is set to be high enough. Heartbeats might be needed in the process, but that is okay.

A problem that could occur here is if multiple RPC sessions are layered onto a single connection, and too many responses are initiated exactly once by the server, which implies some synchronization is necessary.

Current state of what I'm thinking

I'm thinking with all the constraints above in mind, it's hard to layer RPC on top of a connection. Instead we ought to embrace the connection paradigm, and not rise to the req, res abstraction directly. Especially if multiple responses can be initiated from the server. This would be akin to (using Rust-like pseudocode):

#[derive(Debug, Clap)]
struct Cli {
  #[clap(flatten)]
  port: clap::flags::Port,
}

async fn main() -> Result<(), Error> {
  let listener = Cli::from_args().port.bind()?;
  let server = Server::listen(listener);

  for await conn in server {
    await conn.push("pushing data".try_into()?)?;
    await conn.push("pushing more data".try_into()?)?;

    // possibly do auth here. Could do something like conn.take(1), and use that
    // to authenticate the rest of the connection.

    for await req in conn {
      "Hello world".try_into()?;
    }
  }
}

Conclusion

We've explained the difference between classic RPC HTTP servers, and the new Duplex (HTTP/2 & WebSockets) servers. We've asked questions on how to structure the API of these, and highlighted existing examples.

I'd love to hear input on APIs that you've seen work well here. I've heard both Ruby and Python have been tackling these problems for the past few years, but I know little about them and would love to hear more about approached you might have seen.

I hope this somewhat makes sense, and thanks for reading!

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