Feature Name - Freighter - Modular Transport Abstraction
Status - Complete
Start Date - 2022-08-09
Authors - Emiliano Bonilla
In this RFC I propose a design for a protocol agnostic transport abstraction
that can be used for both internal and client communications in a Delta cluster.
The new package, named freighter
, provides generic unary and streaming
interfaces for exchanging messages between a client and server. These interfaces
can be implemented by a variety of languages and protocols (gRPC, Websocket,
WebRTC, HTTP).
Freighter also defines standards for communicating errors between services, allowing exceptions to be communicated in a meaningful manner.
We're currently using the x/transport
package to abstract away gRPC
implementation details for node internal communications. Both Delta's
distribution layer and our Aspen key-value store make use of this package with
success. It provides a crucial separation between core application logic and
networking details, allowing us to swap out different protocols, and test our
application using mock networks (see
x/transport/tmock
). Unfortunately, the x/transport
package defines no
standards for cross-language communication, and focuses its implementation on
the gRPC protocol.
As we develop client libraries in Python, Rust, and TypeScript, we need to extend these transport standards to support other languages and protocols (such as Websockets for the browser).
These extensions should not only include definitions for how a client and server should behave when exchanging messages, but also how some of these messages should be structured. This is particularly import for exceptions, as it's critical to communicate errors in a meaningful manner (such as displaying help text when a user enters invalid data in a form).
At its core, Freighter defines two behavioral interfaces for exchanging messages a client and a server. The unary interface defines a simple request-response cycle, while the streaming interface enables performant asynchronous communication over long periods of time. While I describe them using go idioms, these interfaces can be implemented in many languages.
The unary interface describes a single request-response cycle between a client and a server.
A client can send a payload to a server and receive a response using a send
method with the following signature:
send(target, requestPayload) (responsePayload, error)
target
is the endpoint to send the payload to. An endpoint doesn't have to be
a full address. A client library, for example, may only require the caller to
pass in an endpoint ("/api/v1/foo"). Cluster communications, on the other hand,
may require a host:port pair ("172.16.245.1:8080"). The transport implementation
can handle the construction of a full route or address internally
(gRPC's service registry for example). Target syntax is defined by the transport
implementation.
requestPayload
is the payload to send to the server. This payload encapsulates
all parameters necessary to execute the request (aside from the target, of
course).
responsePayload
is the payload returned by the server after executing the
request.
The encoding of the payloads is left to the transport implementation.
error
is an error that occurs while executing the request. This error can be a
client side validation error, a network failure, or an error returned by the
server. Returning an error as a value resembles the go pattern for error
handling, and some languages typically raise exceptions instead of returning
them. Clients and servers may use this error for control flow (such as
displaying a message to the user), so I'm encouraging implementations to return
errors as values. This allows the caller to parse the error as a data structure,
and make a decision whether to raise an exception, retry the request, or do
something else. Using errors for control flow becomes particularly important
when defining a streaming interface. More details on error handling to follow.
A server can receive a payload from a client by binding a handler to the
transport's
bindHandler
method.
bindHandler(handler(requestPayload) (responsePayload, error))
handler
receives a payload, processes it, and returns a response to the
client. If the server encounters an error during processing, it can return a
non-nil error, which the transport will encode and return to the client.
The streaming interface enables non-blocking bidirectional communication between a client and a server. Streaming interface is complex, requiring delicate control flow. Unary communication should be preferred in cases where performance and asynchronicity are not essential.
Streaming starts with a client issuing a stream
request to a particular
target:
stream(target) (stream, error)
Instead of issuing a request, stream
returns a type that can be used to send
and receive payloads. stream
returns an error when the transport cannot reach
the target or fails to assemble the stream networking infrastructure.
The client-side stream
provides three methods for communication:
send(requestPayload) error
- Sends a payload to the server. Send is
non-blocking, so delivery of the payload is not guaranteed, as the stream may
close before all payloads have been exchanged. Send returns an error when:
-
The server closes the stream -> Returns an
EOF
(end-of-file) error letting the caller know that the server is no longer accepting messages. If the server closes the stream with an abnormal error, send will still return anEOF
error. The client can discover the error the server closed the stream with by callingreceive
. -
The client calls
closeSend
-> Returns aStreamClosed
error letting the caller know that the client is no longer accepting messages. This case represents a bug in the application, and listening for aStreamClosed
error should not be used for control flow. -
The transport implementation fails -> Returns the error that caused the transport to fail.
receive() (responsePayload, error)
- Blocks until a payload is received from
the server or the stream closes. Receive returns an error when:
-
The server closes stream nominally (i.e. returns a nil error from the handler) -> Returns an
EOF
error letting the caller know that the server is no longer exchanging messages. -
The server closes the stream abnormally (returns a non-nil error) -> Returns the error that caused the stream to close.
-
The transport implementation fails -> Returns the error that caused the transport
-
to fail.
closeSend() error
- Closes the sending direction of the stream, letting the
server know the client will no issue new payloads. closeSend
is idempotent,
and will only return an error if the underlying transport implementation fails.
It's important to note that closeSend
is purely for flow-control, and has no
impact on the receiving direction of the stream. In most cases, clients should
wait for receive
to return a non-nil error to ensure they have received all
relevant payloads.
A server can bind a handler to the streaming transport using a bindHandler
function:
bindHandler(handler(stream) error)
handler
provides a stream that can communicate with the client using send
and
receive
methods:
receive() (requestPayload, error)
- Blocks until a payload is received from
the client. Returns an error when:
-
The client calls
closeSend
-> Returns anEOF
error letting the server know it should process remaining payloads and then close the stream. -
The handler returns -> Returns a
StreamClosed
error letting the server know the receiving direction of the stream is no longer accepting messages. This case typically represent a bug in the application, and listening for aStreamClosed
error should not be used for control flow. -
The transport implementation fails -> Returns the error that caused the transport to fail.
send(responsePayload) error
: Sends a payload to the server. As with client
streams,
send
does not block, so delivery is not guaranteed. send
returns an error
when:
-
The handler has returned -> Returns a
StreamClosed
error letting the server know the sending direction of the stream is no longer accepting messages. As withreceive
, this case typically represents a bug in the application, and listening for aStreamClosed
error should not be used for control flow. -
If the transport implementation fails -> Returns the error that caused the transport to fail.
Unlike a client stream, a server stream has no closeSend
a method. Instead,
the caller can close the stream by returning an error from the handler. If the
error is nil, the client will receive an EOF
error. If the error is non-nil,
the transport implementation encodes the error and returns it to the client.
To better understand the lifecycle of a streaming request, let's look at a simple example where a client sends a stream of integers to a server, the server squares each integer, and then returns the result.
- Client calls
stream
with the server's address and receives a client stream to exchange payloads with. - Server opens a new handler and provides it with a client stream to exchange payloads with.
- Client sends the integers 1, 2, 3, and 4 to the server.
- Client calls
closeSend
to indicate that it has no more integers to send. - Server receives each integer, squares it, and sends the result to the client.
After receiving an
EOF
error, the server exits a loop and returns a nil error to the handler. - Client receives payloads in a loop until it receives an
EOF
error, indicating the end of communication.
That's it!