How we can add some structure where there is none?
With REST for example, things are easy. It's built on HTTP. HTTP has single request and response on a single connection. It's defined on it's convention like methods and URL paths. What happens when we venture out of that?
It's important to understand that once we venture outside the narrow world of web services, we don't have to make up our own BS and things can be just as simple. Let's look at the types of stuff we can do with simple RPC protocols.
It makes sense to start with XML-RPC because it’s the oldest mainstream RPC format that people still remember. Its ideas go back decades. Conceptually, it's the same as HTTP's request response model. Its successor, SOAP (Simple Object Access Protocol), took the same concept and layered on schemas, WSDL, extensions, and enterprise-grade everything. SOAP is still widely used in enterprise systems.
XML-RPC itself is intentionally simple:
It has a list of data types it supports: string, int, boolean, double, dateTime.iso8601, base64, struct (object), and array.
A methodCall (request) leads to a methodResponse (success) or a fault (error). Nothing more.
From Wikipedia:
<?xml version="1.0"?>
<methodCall>
<methodName>examples.getStateName</methodName>
<params>
<param>
<value><i4>40</i4></value>
</param>
</params>
</methodCall>
<?xml version="1.0"?>
<methodResponse>
<params>
<param>
<value><string>South Dakota</string></value>
</param>
</params>
</methodResponse>
<methodResponse>
<fault>
<value>
<struct>
<member>
<name>faultCode</name>
<value><int>4</int></value>
</member>
<member>
<name>faultString</name>
<value><string>Too many parameters.</string></value>
</member>
</struct>
</value>
</fault>
</methodResponse>
fault response is standardized.string, int, boolean, struct, etc.DOM, SAX, etc.).You can see JSON-RPC, at least version 1.0, as the exact translation of XML-RPC to JSON with one important improvement: the id field.
The id field unlocked 2 thing:
{
"method": "examples.getStateName",
"params": [40],
"id": 1
}
{
"result": "South Dakota",
"error": null,
"id": 1
}
{
"result": null,
"error": {
"code": 4,
"message": "Too many parameters."
},
"id": 1
}
{
"method": "examples.updateState",
"params": ["South Dakota"],
"id": null
}
id field allows for concurrent requests and responses.id field opens up more transport options including non-session based like UDP. I once implemented JSON-RPC over a pubsub event streaming setup.I see JSON-RPC 2.0 as a misguided attempt to fix things that were not broken in 1.0.
It restricted architecture to server-client only (no peer-to-peer), removed notifications, and made the params field support only by-name or by-position (not both). It also added some complexity around error handling and added the jsonrpc version field bloat to each message.
This is a prime example of how things in the technology world can get worse with "improvements".
JSON-RPC is very simple and useful. But it's not enough for all types of niche use cases. These are some of the things I have seen and done to make JSON-RPC fit.
Note that these are highly subjective and there are no standards for these (that I know of).
One of the great additions you can add to JSON-RPC is the concept of acknowledgments.
So things flow like this:
id.{ "ack": <same id> }.{ "result": <actual result>, "id": <same id> } or an error if something went wrong.Or if the callee is able to process the request immediately, it can skip the acknowledgment step and just send the final response right away.
This is backward compatible with negligible changes to any JSON-RPC implementation.
This is especially helpful in lossy transport scenarios where you are not sure if the receiver got the request or not and don't want to wait around few minutes for the connection to time out. You send the request and expect to hear back something within a few seconds (~ < 10s). If you don't hear anything, you can either retry the request or time out caller straight away.
IMO it's better to retry a few times and then time out.
With ACKing, one thing to think about in your protocol is are you going to ack requests or messages.
In the above scenario for example, what if the response message gets lost? You can decide between adding a resultAck message or having a separate messageId field and making ack a feature of messages.
Here is an example of how message level id/ACK would work.
Example request (A -> B):
{
"method": "examples.getStateName",
"params": [40],
"id": 1,
"messageId": 666
}
ACK can be (A <- B):
{"ack": 666}
And then when response is ready (A <- B):
{
"result": "South Dakota",
"error": null,
"id": 1,
"messageId": 413
}
and the response to that is (A -> B):
{
"ack": 413
}
This means if the ack got lost, you would re-try sending the response. That is - you are keeping some state.
Note here that each entity manages it's own serial of messages. This means that each entity in the system has to keep state of each entity it's talking to.
It's when you send more than one request per packet.
For example, say you have a source of event/requests that is generating hundreds of events per second. And the payload is not that big. Do you fire up 100s of requests per second?
A useful pattern is to buffer requests and send them in batches. For example, if you decide the max transmission rate will be 30 requests per second. That's 1 request every ~33ms. You can buffer requests for 33ms and send everything buffered in that time all at once in a single packet.
With this, you agree on beforehand and always transmit requests and responses in arrays. For example:
[
{ "method": "event.update", "params": [...], "id": 1 },
{ "method": "event.update", "params": [...], "id": 2 },
...
]
Re-transmit recently sent requests until acknowledged.
I've used this in lossy but high performance scenarios. For example, you need to get the message across within a certain deadline. But waiting for acknowledgment or timing out and retrying would exceed that deadline. What do you do in that scenario?
You keep a fixed length buffer (< MTU - overhead) of requests and send them all at once in a single packet. So when you send request id = 10, you also include request id = 9 (previously sent) and request = 8 (sent before that) and so on as long as they fit in the buffer.
Part of this payload, you also ack the latest successfully received request id from the other side (also part of each packet).
Once you receive an ack, you trim your buffer to only keep unacknowledged requests and keep sending.
The application where I had to implement this was a dead reckoning system scenario. Payload was in 10s of bytes. If the buffer built up to be too big, the systems had already de-synchronised by then and we had to drop the connection, re-calibrate and, re-sync which was a much more expensive operation.
Using TCP is not better in these scenarios. It's less work for you but you will miss more deadlines.
Note: One gotcha to think about with this is idempotence. How do we prevent processing the same request twice? This means you might have to keep some request state past the lifecycle of a request or have some prior agreements about serial numbers used (e.g. we will always process requests in sequence so out of sequence requests get ignored).
Sometimes a request can lead to a long-running operation that produces multiple results over time.
In this scenario, as the id field is being used to identify the overall request, you need to introduce a secondary Id, say streamId or chunkId or equivalent that identifies the chunk of result. It's not necessary you have message level ack and also does not matter over TCP. Otherwise it is important if you are doing this over lossy transport like UDP where there is a chance of packet loss and packets going out of sequence.
It's also a good idea to have a resultChunk or equivalent response to to identify that this is part of a stream of results instead of result.
That way, result can be reserved for the final result when the stream ends and it remains backward compatible with previous implementations with minor modifications.
Note: streaming can be bidirectional too. You need to keep state for each stream.
One time I've had to rely on chunking because the result was too big and had to be sent in chunks. You can use all the ideas from streaming responses for this.
If you are using JSON-RPC for operations anyway, why not also stream logs and errors back to the caller as they happen instead of waiting for the entire operation to finish? I used this for a project once where users would run commands on the server and it would stream back logs and errors as they happened. This improved user experience significantly.
It's a good idea to introduce a debug-log and debug-error response type for this. For example:
{
"debug-log": "This is a log message",
"id": 1,
}
{
"debug-error": {
"message": "An error occurred"
},
"id": 1,
}
I had another project where I used a special flag to convert normal REST api calls stream logs and errors for debugging. It would use HTTP chunked transfer encoding to stream logs back to the caller as they happened.
RPC protocols like XML-RPC and JSON-RPC provide structured ways to perform remote procedure calls. While XML-RPC is largely obsolete, JSON-RPC remains relevant due to its simplicity and flexibility. By extending JSON-RPC with features like acknowledgments, batching, sliding windows, and streaming, developers can tailor the protocol to meet specific application needs, especially in scenarios involving lossy transports or long-running operations.