Skip to content

Commit 3c3461e

Browse files
committed
Handle exceptions in ParallelRpcClient.GetFirstSuccessAsync
Previously, if one RPC node threw an exception (e.g. ConnectTimeout), GetFirstSuccessAsync would propagate it immediately, ignoring results from other nodes still in flight. This could cause lost transaction results when broadcasting to multiple nodes. Now exceptions from individual tasks are caught and treated as errors, allowing the method to continue waiting for remaining nodes. User- initiated cancellation via CancellationToken still propagates normally.
1 parent e33ed2b commit 3c3461e

3 files changed

Lines changed: 99 additions & 11 deletions

File tree

Revelium.Evm.Tests/Rpc/ParallelRpcTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ private static void SetupNetworkError(Mock<HttpMessageHandler> handler, string u
5353
.ThrowsAsync(new HttpRequestException("Network error"));
5454
}
5555

56+
private static void SetupTimeout(Mock<HttpMessageHandler> handler, string url)
57+
{
58+
handler
59+
.Protected()
60+
.Setup<Task<HttpResponseMessage>>(
61+
"SendAsync",
62+
ItExpr.Is<HttpRequestMessage>(req => req.RequestUri!.ToString() == url),
63+
ItExpr.IsAny<CancellationToken>())
64+
.ThrowsAsync(new TaskCanceledException("Connect timeout"));
65+
}
66+
5667
private static void SetupRpcError(Mock<HttpMessageHandler> handler, string url, int errorCode, string errorMessage)
5768
{
5869
var response = new
@@ -490,4 +501,67 @@ await Assert.ThrowsAsync<InvalidOperationException>(async () =>
490501
await parallelClient.GetBalanceAsync(ADDRESS);
491502
});
492503
}
504+
505+
[Fact]
506+
public async Task Test_ParallelRpcClient_FirstClientTimeout_SecondSucceeds()
507+
{
508+
// Arrange
509+
var handler1 = new Mock<HttpMessageHandler>();
510+
var handler2 = new Mock<HttpMessageHandler>();
511+
SetupTimeout(handler1, RPC_URL_1);
512+
SetupJsonRpcResponseResult(handler2, RPC_URL_2, "0xb9052a11d665600");
513+
514+
var client1 = CreateRpcClient(RPC_URL_1, handler1);
515+
var client2 = CreateRpcClient(RPC_URL_2, handler2);
516+
var parallelClient = new ParallelRpcClient([client1, client2], []);
517+
518+
// Act
519+
var (balance, error) = await parallelClient.GetBalanceAsync(ADDRESS);
520+
521+
// Assert
522+
Assert.Null(error);
523+
Assert.Equal(833256783000000000, balance);
524+
}
525+
526+
[Fact]
527+
public async Task Test_ParallelRpcClient_AllClientsTimeout_ReturnsError()
528+
{
529+
// Arrange
530+
var handler1 = new Mock<HttpMessageHandler>();
531+
var handler2 = new Mock<HttpMessageHandler>();
532+
SetupTimeout(handler1, RPC_URL_1);
533+
SetupTimeout(handler2, RPC_URL_2);
534+
535+
var client1 = CreateRpcClient(RPC_URL_1, handler1);
536+
var client2 = CreateRpcClient(RPC_URL_2, handler2);
537+
var parallelClient = new ParallelRpcClient([client1, client2], []);
538+
539+
// Act
540+
var (_, error) = await parallelClient.GetBalanceAsync(ADDRESS);
541+
542+
// Assert
543+
Assert.NotNull(error);
544+
Assert.Equal(RpcClient.HTTP_REQUEST_ERROR, error.Code);
545+
}
546+
547+
[Fact]
548+
public async Task Test_ParallelRpcClient_SendRawTransaction_BroadcastTimeout_SecondSucceeds()
549+
{
550+
// Arrange
551+
var broadcastHandler1 = new Mock<HttpMessageHandler>();
552+
var broadcastHandler2 = new Mock<HttpMessageHandler>();
553+
SetupTimeout(broadcastHandler1, BROADCAST_URL_1);
554+
SetupJsonRpcResponseResult(broadcastHandler2, BROADCAST_URL_2, "0x1234567890abcdef");
555+
556+
var broadcastClient1 = CreateRpcClient(BROADCAST_URL_1, broadcastHandler1);
557+
var broadcastClient2 = CreateRpcClient(BROADCAST_URL_2, broadcastHandler2);
558+
var parallelClient = new ParallelRpcClient([], [broadcastClient1, broadcastClient2]);
559+
560+
// Act
561+
var (txHash, error) = await parallelClient.SendRawTransactionAsync("0xabcdef123456");
562+
563+
// Assert
564+
Assert.Null(error);
565+
Assert.Equal("0x1234567890abcdef", txHash);
566+
}
493567
}

Revelium.Evm/Revelium.Evm.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
<RepositoryUrl>https://github.com/matsakiv/Revelium.Evm</RepositoryUrl>
1717

1818
<Authors>mativ</Authors>
19-
<Version>0.0.24</Version>
19+
<Version>0.0.25</Version>
2020
<Description>Revelium.Evm is .NET integration library for EVM-compatible networks</Description>
2121
<Copyright>Copyright © Igor Matsak</Copyright>
2222
<Title>Revelium.Evm</Title>

Revelium.Evm/Rpc/ParallelRpcClient.cs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,21 @@ private async Task<Result<TValue>> GetFirstSuccessAsync<TValue>(
5353
break;
5454

5555
var completedTask = await Task.WhenAny(remainingTasks);
56-
57-
var (result, error) = await completedTask;
5856
completedTasks.Add(completedTask);
5957

60-
if (error == null)
61-
return result;
58+
try
59+
{
60+
var (result, error) = await completedTask;
61+
62+
if (error == null)
63+
return result;
6264

63-
lastError = error;
65+
lastError = error;
66+
}
67+
catch (Exception) when (!cancellationToken.IsCancellationRequested)
68+
{
69+
lastError = new Error(RpcClient.HTTP_REQUEST_ERROR, "Request failed");
70+
}
6471
}
6572

6673
// All tasks completed with errors, return the last error
@@ -97,14 +104,21 @@ private async Task<NullableResult<TValue>> GetFirstSuccessAsync<TValue>(
97104
break;
98105

99106
var completedTask = await Task.WhenAny(remainingTasks);
100-
101-
var (result, error) = await completedTask;
102107
completedTasks.Add(completedTask);
103108

104-
if (error == null)
105-
return result;
109+
try
110+
{
111+
var (result, error) = await completedTask;
112+
113+
if (error == null)
114+
return result;
106115

107-
lastError = error;
116+
lastError = error;
117+
}
118+
catch (Exception) when (!cancellationToken.IsCancellationRequested)
119+
{
120+
lastError = new Error(RpcClient.HTTP_REQUEST_ERROR, "Request failed");
121+
}
108122
}
109123

110124
// All tasks completed with errors, return the last error

0 commit comments

Comments
 (0)