-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add support for batched JSON-RPC requests #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
8f0a69c to
0309f4f
Compare
0309f4f to
0410582
Compare
8b24b98 to
dce5081
Compare
|
@gregorydemay I did quite a bit of experimenting here with different solutions to make this as easy to use as possible despite all the generics involved with |
|
@gregorydemay One thing in particular I thought we could discuss: JSON-RPC batch responses are not necessarily in the same order as the requests. At some point, we will need to match responses to requests using IDs. I actually thought we could do this in the response filter where we check for consistent IDs. We could then return a vector of Generally, I think in the end we might have per-response errors and overall errors for the batch so the return type from the |
gregorydemay
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot @lpahlavi for starting this effort!
| // the current height of the Solana blockchain with different commitment requirements. | ||
| let requests = http::Request::post(solana_test_validator_base_url()) | ||
| .header("Content-Type", "application/json") | ||
| .body(vec![ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice!
Maybe it would also be good to have an example with heterogeneous methods, where the batch request would contain requests for different methods?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea! Done.
| .map(|(response, expected_id)| { | ||
| let (id, result) = response.into_parts(); | ||
| assert_eq!(id, expected_id.into()); | ||
| result.expect("JSON-RPC API call should succeed") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: should we maybe print out the results to have something a bit more tangible when running the example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added some print statements, but AFAIK we can't see them since print statements from canisters are not show, right?
| .collect() | ||
| } | ||
|
|
||
| fn batch_json_rpc_client<Params, Result>() -> impl Service< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
understanding question: This feels very similar to json_rpc_client. Why are 2 clients needed? If a client can do JSON-RPC batch it could also do it with a single request, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very good question! The function contents are the exact same, the only difference is the Service generic types. If I want this method to work for both single and batched requests, I need to make it generic and IMO it might end up being a bit more confusing:
fn client<Request, Response>(
) -> impl Service<http::Request<Request>, Response = http::Response<Response>, Error = BoxError>
where
Request: Debug + Serialize,
Response: Debug + DeserializeOwned,
CreateJsonRpcIdFilter<Request, Response>:
CreateResponseFilter<http::Request<Request>, http::Response<Response>>,
<CreateJsonRpcIdFilter<Request, Response> as CreateResponseFilter<
http::Request<Request>,
http::Response<Response>,
>>::Error: Into<BoxError>,
{
ServiceBuilder::new()
// Print request, response and errors to the console
.layer(observability_layer())
// Convert request and response batches to JSON-RPC over HTTP and validate response IDs
.layer(JsonRpcHttpLayer::new())
// Use cycles from the canister to pay for HTTPs outcalls
.cycles_accounting(ChargeMyself::default())
// The actual client
.service(Client::new_with_box_error())
}WDYT?
| use tower::{BoxError, Service, ServiceBuilder, ServiceExt}; | ||
| use super::*; | ||
|
|
||
| // TODO: Add tests for batch JSON-RPC |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO (this comment is to avoid forgetting about it)
| /// } | ||
| /// ``` | ||
| /// | ||
| /// Create a simple batch JSON-RPC over HTTP client. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems very similar to the previous example and it's a priori difficult to see what the exact difference is. Maybe we could have a .batch method that transforms a service Service< HttpJsonRpcRequest<Params>, HttpJsonRpcResponse<Result>, Error > into an Service< HttpBatchJsonRpcRequest<Params>, HttpBatchJsonRpcResponse<Result>, Error >?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that would be possible because we would want to modify some of the internal middlewares within the service to achieve that. See this thread, but it's possible to build a generic method for the client that handles both types of requests.
| Result: DeserializeOwned, | ||
| Request: Serialize, | ||
| Response: DeserializeOwned, | ||
| CreateJsonRpcIdFilter<Request, Response>: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
understanding question: why do we need this new trait bound?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is because the generic changed from just the contents of a JsonRpcRequest/JsonRpcResponse to now the contents of the http::Request/http::Response.
canhttp/src/http/json/response.rs
Outdated
| /// Ensure that the ID of the response is consistent with the one from the request | ||
| /// that is stored internally. | ||
| pub struct ConsistentJsonRpcIdFilter<O> { | ||
| pub struct ConsistentJsonRpcIdFilter<Request, Response, Id> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instead of being generic over Id wouldn't it be easier to make request_id a BTreeSet<Id>(in the case of non-batch it's then a singleton)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hesitated between the two, for me it doesn't make a big difference, but a singleton is also OK. The only thing is that there would have to be an assert when checking the response that the saved request ID is indeed a singleton when looking at a single JSON-RPC request ID.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hesitated between the two, for me it doesn't make a big difference, but a singleton is also OK.
I think it's useful to avoid generics if we can since they spread a lot (need additional generic parameter everywhere).
assert when checking the response that the saved request ID is indeed a singleton when looking at a single JSON-RPC request ID.
I don't see why the logic for checking a single request ID should be different from that of checking a set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a small refactoring to remove the generic and always use a Vec instead (since we now care about the request ordering).
canhttp/src/http/json/response.rs
Outdated
| .body() | ||
| .iter() | ||
| .map(expected_response_id) | ||
| .collect::<BTreeSet<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't there be an error if the given IDs are non-unique?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! I guess an assert here would not be ideal, though it could only be caused by an error in the client. WDYT? Should we add a fallible CreateResponseFilter trait so we can return an error when creating the filter (like TryCreateResponseFilter or something)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess an assert here would not be ideal, though it could only be caused by an error in the client.
An error seems like it would be nicer, although we already have a panic when ensuring that the request IDs are not null.
canhttp/src/http/json/response.rs
Outdated
| .body() | ||
| .iter() | ||
| .map(|response| response.id()) | ||
| .collect::<BTreeSet<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would not detect duplicate IDs in the response. I'm not sure I follow the logic with counting, I think it's not enough, one needs to identify the potential differences. How about something like
- Request IDs are in a BTreeSet (by construction)
- Collect response IDs in a vec.
- Check cardinality of request IDs matches that of response IDs.
- Kick out the request IDs that should be null according to the response
- Collect responds IDs from vec to a BtreeSet, filtering out null elements.
- Ensure both sets are equal.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, overall though I think it might be actually better to return something like a vector of something like Result<JsonRpcResponse, NoResponseForThisIdError>. I think it would probably make sense to simply ignore responses with an unexpected ID (as we do in the single request case) and return a vector of responses that are ordered the same as the requests, based on corresponding IDs. WDYT? (See this comment.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a look at making the return value of the service a Result<Vec<Result<JsonRpcResponse, _>>, _> but I changed my mind regarding this. The whole API becomes quite annoying to use (there are per-response errors and per-batch errors), only to handle the case where the server returns a batch with inconsistent IDs. In the end, I feel it's a good enough trade-off to just return an error for the whole batch in this case (i.e. inconsistent IDs in the batch leads to the whole batch response being an error).
However, I do feel that we should re-order the responses here to be in the same order as the requests, correlating with response IDs.
Now, concerning the null response IDs.
- Kick out the request IDs that should be null according to the response
This, we can't do, since we can't correlate the responses with a null ID to a request ID in particular. Meaning we have to count the number of responses with null IDs and make sure this corresponds with the number of request IDs for which we don't have a corresponding response.
I had a closer look at the specs, and IIUC correctly, a null response ID should only occur with either a parse error, or an invalid request error (for which the error code and message are standardized in the specs). However, a parse error (i.e. the request is not valid JSON) should lead to a single JSON error object being returned (NOT an array). This means we can validate here that responses with a null ID should only be invalid request errors. We can then fill-in the response for all missing request IDs with the same invalid request error.
TL;DR The logic is the following:
- Request IDs are in a vector (to record the request ordering)
- Count the number of responses with null IDs and validate that they are "invalid request" errors
- Store the remaining response IDs in a map,
- Validate that there are no duplicate items (while building the map)
- Correlate responses with request IDs
- Validate that num(correlated responses) + num(responses with null IDs) = num(request IDs)
- Construct a vector with the correlated responses and an invalid request error for request IDs with no corresponding error
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's discuss the remaining open points online, I think it might be more efficient.
lpahlavi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot for the review and input @gregorydemay! I've left some comments and made some improvements to the examples. Let me know what you think!
| // the current height of the Solana blockchain with different commitment requirements. | ||
| let requests = http::Request::post(solana_test_validator_base_url()) | ||
| .header("Content-Type", "application/json") | ||
| .body(vec![ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea! Done.
| .map(|(response, expected_id)| { | ||
| let (id, result) = response.into_parts(); | ||
| assert_eq!(id, expected_id.into()); | ||
| result.expect("JSON-RPC API call should succeed") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added some print statements, but AFAIK we can't see them since print statements from canisters are not show, right?
| .collect() | ||
| } | ||
|
|
||
| fn batch_json_rpc_client<Params, Result>() -> impl Service< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very good question! The function contents are the exact same, the only difference is the Service generic types. If I want this method to work for both single and batched requests, I need to make it generic and IMO it might end up being a bit more confusing:
fn client<Request, Response>(
) -> impl Service<http::Request<Request>, Response = http::Response<Response>, Error = BoxError>
where
Request: Debug + Serialize,
Response: Debug + DeserializeOwned,
CreateJsonRpcIdFilter<Request, Response>:
CreateResponseFilter<http::Request<Request>, http::Response<Response>>,
<CreateJsonRpcIdFilter<Request, Response> as CreateResponseFilter<
http::Request<Request>,
http::Response<Response>,
>>::Error: Into<BoxError>,
{
ServiceBuilder::new()
// Print request, response and errors to the console
.layer(observability_layer())
// Convert request and response batches to JSON-RPC over HTTP and validate response IDs
.layer(JsonRpcHttpLayer::new())
// Use cycles from the canister to pay for HTTPs outcalls
.cycles_accounting(ChargeMyself::default())
// The actual client
.service(Client::new_with_box_error())
}WDYT?
canhttp/src/http/json/response.rs
Outdated
| .body() | ||
| .iter() | ||
| .map(expected_response_id) | ||
| .collect::<BTreeSet<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! I guess an assert here would not be ideal, though it could only be caused by an error in the client. WDYT? Should we add a fallible CreateResponseFilter trait so we can return an error when creating the filter (like TryCreateResponseFilter or something)?
canhttp/src/http/json/response.rs
Outdated
| /// Ensure that the ID of the response is consistent with the one from the request | ||
| /// that is stored internally. | ||
| pub struct ConsistentJsonRpcIdFilter<O> { | ||
| pub struct ConsistentJsonRpcIdFilter<Request, Response, Id> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hesitated between the two, for me it doesn't make a big difference, but a singleton is also OK. The only thing is that there would have to be an assert when checking the response that the saved request ID is indeed a singleton when looking at a single JSON-RPC request ID.
canhttp/src/http/json/response.rs
Outdated
| .body() | ||
| .iter() | ||
| .map(|response| response.id()) | ||
| .collect::<BTreeSet<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, overall though I think it might be actually better to return something like a vector of something like Result<JsonRpcResponse, NoResponseForThisIdError>. I think it would probably make sense to simply ignore responses with an unexpected ID (as we do in the single request case) and return a vector of responses that are ordered the same as the requests, based on corresponding IDs. WDYT? (See this comment.)
| Result: DeserializeOwned, | ||
| Request: Serialize, | ||
| Response: DeserializeOwned, | ||
| CreateJsonRpcIdFilter<Request, Response>: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is because the generic changed from just the contents of a JsonRpcRequest/JsonRpcResponse to now the contents of the http::Request/http::Response.
| /// } | ||
| /// ``` | ||
| /// | ||
| /// Create a simple batch JSON-RPC over HTTP client. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that would be possible because we would want to modify some of the internal middlewares within the service to achieve that. See this thread, but it's possible to build a generic method for the client that handles both types of requests.
(DEFI-2565) Add support for JSON-RPC batch requests and responses. This mostly involves implementing existing JSON-RPC middlewares for JSON-RPC batches in addition to JSON-RPC single requests.