Skip to content

Commit cf553ef

Browse files
zeljkoXtirumerla
andauthored
fix: Extend non retriable rpc messages (#696)
* fix: Extend non retriable rpc messages * chore: PR suggestion --------- Co-authored-by: tirumerla <tirumerla@gmail.com>
1 parent e179128 commit cf553ef

4 files changed

Lines changed: 237 additions & 9 deletions

File tree

src/constants/evm_transaction.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,29 @@ pub fn get_evm_status_check_initial_delay() -> Duration {
9696
pub fn get_evm_min_age_for_hash_recovery() -> Duration {
9797
Duration::minutes(EVM_MIN_AGE_FOR_HASH_RECOVERY_MINUTES)
9898
}
99+
100+
/// Error message patterns indicating a transaction was already submitted or its nonce consumed.
101+
/// Shared between the retry layer (`is_non_retriable_transaction_rpc_message`) and
102+
/// the domain layer (`is_already_submitted_error`).
103+
///
104+
/// Each entry is a lowercased substring to match against the RPC error message.
105+
pub const ALREADY_SUBMITTED_PATTERNS: &[&str] = &[
106+
"nonce too low",
107+
"nonce is too low",
108+
"already known",
109+
"replacement transaction underpriced",
110+
"same hash was already imported",
111+
];
112+
113+
/// Checks if a lowercased message matches "known transaction" without matching
114+
/// "unknown transaction" (substring false positive).
115+
pub fn matches_known_transaction(msg_lower: &str) -> bool {
116+
if let Some(pos) = msg_lower.find("known transaction") {
117+
// Reject if preceded by "un" (i.e. "unknown transaction")
118+
if msg_lower[..pos].ends_with("un") {
119+
return false;
120+
}
121+
return true;
122+
}
123+
false
124+
}

src/domain/transaction/evm/evm_transaction.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ use std::sync::Arc;
1111
use tracing::{debug, error, info, warn};
1212

1313
use crate::{
14-
constants::{DEFAULT_EVM_GAS_LIMIT_ESTIMATION, GAS_LIMIT_BUFFER_MULTIPLIER},
14+
constants::{
15+
matches_known_transaction, ALREADY_SUBMITTED_PATTERNS, DEFAULT_EVM_GAS_LIMIT_ESTIMATION,
16+
GAS_LIMIT_BUFFER_MULTIPLIER,
17+
},
1518
domain::{
1619
evm::is_noop,
1720
transaction::{
@@ -145,11 +148,15 @@ where
145148

146149
/// Checks if a provider error indicates the transaction was already submitted to the blockchain.
147150
/// This handles cases where the transaction was submitted by another instance or in a previous retry.
151+
///
152+
/// Uses the shared `ALREADY_SUBMITTED_PATTERNS` from constants, consistent with
153+
/// `is_non_retriable_transaction_rpc_message` in `services::provider`.
148154
fn is_already_submitted_error(error: &impl std::fmt::Display) -> bool {
149155
let error_msg = error.to_string().to_lowercase();
150-
error_msg.contains("already known")
151-
|| error_msg.contains("nonce too low")
152-
|| error_msg.contains("replacement transaction underpriced")
156+
ALREADY_SUBMITTED_PATTERNS
157+
.iter()
158+
.any(|p| error_msg.contains(p))
159+
|| matches_known_transaction(&error_msg)
153160
}
154161

155162
/// Helper method to schedule a transaction status check job.
@@ -2774,6 +2781,22 @@ mod tests {
27742781
&"Error: nonce too low"
27752782
));
27762783

2784+
// Test "nonce is too low" variants (some providers use this wording)
2785+
assert!(DefaultEvmTransaction::is_already_submitted_error(
2786+
&"nonce is too low"
2787+
));
2788+
assert!(DefaultEvmTransaction::is_already_submitted_error(
2789+
&"Error: nonce is too low"
2790+
));
2791+
2792+
// Test "known transaction" variants (Besu)
2793+
assert!(DefaultEvmTransaction::is_already_submitted_error(
2794+
&"known transaction"
2795+
));
2796+
assert!(DefaultEvmTransaction::is_already_submitted_error(
2797+
&"Known Transaction"
2798+
));
2799+
27772800
// Test "replacement transaction underpriced" variants
27782801
assert!(DefaultEvmTransaction::is_already_submitted_error(
27792802
&"replacement transaction underpriced"
@@ -2782,6 +2805,11 @@ mod tests {
27822805
&"Replacement Transaction Underpriced"
27832806
));
27842807

2808+
// Test "same hash was already imported" (OpenEthereum)
2809+
assert!(DefaultEvmTransaction::is_already_submitted_error(
2810+
&"same hash was already imported"
2811+
));
2812+
27852813
// Test non-matching errors
27862814
assert!(!DefaultEvmTransaction::is_already_submitted_error(
27872815
&"insufficient funds"
@@ -2795,6 +2823,10 @@ mod tests {
27952823
assert!(!DefaultEvmTransaction::is_already_submitted_error(
27962824
&"timeout"
27972825
));
2826+
// "unknown transaction" must NOT match "known transaction"
2827+
assert!(!DefaultEvmTransaction::is_already_submitted_error(
2828+
&"Unknown transaction status"
2829+
));
27982830
}
27992831

28002832
/// Test submit_transaction with "already known" error in Sent status

src/services/provider/mod.rs

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use tracing::debug;
77

88
use crate::config::ServerConfig;
99
use crate::constants::{
10+
matches_known_transaction, ALREADY_SUBMITTED_PATTERNS,
1011
DEFAULT_HTTP_CLIENT_CONNECT_TIMEOUT_SECONDS,
1112
DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_INTERVAL_SECONDS,
1213
DEFAULT_HTTP_CLIENT_HTTP2_KEEP_ALIVE_TIMEOUT_SECONDS,
@@ -441,6 +442,20 @@ pub fn should_mark_provider_failed(error: &ProviderError) -> bool {
441442
}
442443
}
443444

445+
/// Returns true if the RPC error message indicates a transaction-level error
446+
/// that should not be retried — the RPC is working correctly, but rejecting
447+
/// the transaction itself.
448+
///
449+
/// Uses the shared `ALREADY_SUBMITTED_PATTERNS` from constants, consistent with
450+
/// `is_already_submitted_error` in `domain::transaction::evm::evm_transaction`.
451+
fn is_non_retriable_transaction_rpc_message(message: &str) -> bool {
452+
let msg_lower = message.to_lowercase();
453+
ALREADY_SUBMITTED_PATTERNS
454+
.iter()
455+
.any(|p| msg_lower.contains(p))
456+
|| matches_known_transaction(&msg_lower)
457+
}
458+
444459
// Errors that are retriable
445460
pub fn is_retriable_error(error: &ProviderError) -> bool {
446461
match error {
@@ -470,14 +485,16 @@ pub fn is_retriable_error(error: &ProviderError) -> bool {
470485
}
471486

472487
// JSON-RPC error codes (EIP-1474)
473-
ProviderError::RpcErrorCode { code, .. } => {
488+
ProviderError::RpcErrorCode { code, message } => {
474489
match code {
475-
// -32002: Resource unavailable (temporary state)
476-
-32002 => true,
490+
// -32002: Resource unavailable — retriable unless the message indicates a
491+
// transaction-level rejection (some providers wrap nonce/tx errors here)
492+
-32002 => !is_non_retriable_transaction_rpc_message(message),
477493
// -32005: Limit exceeded / rate limited
478494
-32005 => true,
479-
// -32603: Internal error (may be temporary)
480-
-32603 => true,
495+
// -32603: Internal error — retriable unless the message indicates a
496+
// transaction-level rejection (some providers wrap nonce/tx errors here)
497+
-32603 => !is_non_retriable_transaction_rpc_message(message),
481498
// -32000: Invalid input
482499
-32000 => false,
483500
// -32001: Resource not found
@@ -1320,4 +1337,85 @@ mod tests {
13201337
);
13211338
}
13221339
}
1340+
1341+
#[test]
1342+
fn test_is_non_retriable_transaction_rpc_message() {
1343+
// Positive cases: these messages should be recognized as non-retriable
1344+
assert!(is_non_retriable_transaction_rpc_message("nonce too low"));
1345+
assert!(is_non_retriable_transaction_rpc_message("Nonce Too Low"));
1346+
assert!(is_non_retriable_transaction_rpc_message("nonce is too low"));
1347+
assert!(is_non_retriable_transaction_rpc_message("already known"));
1348+
assert!(is_non_retriable_transaction_rpc_message(
1349+
"known transaction"
1350+
));
1351+
assert!(is_non_retriable_transaction_rpc_message(
1352+
"Known Transaction"
1353+
));
1354+
assert!(is_non_retriable_transaction_rpc_message(
1355+
"replacement transaction underpriced"
1356+
));
1357+
assert!(is_non_retriable_transaction_rpc_message(
1358+
"same hash was already imported"
1359+
));
1360+
assert!(is_non_retriable_transaction_rpc_message(
1361+
"Transaction nonce too low"
1362+
));
1363+
1364+
// Negative cases: generic/unrelated messages should not match
1365+
assert!(!is_non_retriable_transaction_rpc_message("Internal error"));
1366+
assert!(!is_non_retriable_transaction_rpc_message("server busy"));
1367+
assert!(!is_non_retriable_transaction_rpc_message(""));
1368+
// "unknown transaction" must NOT match "known transaction"
1369+
assert!(!is_non_retriable_transaction_rpc_message(
1370+
"Unknown transaction status"
1371+
));
1372+
}
1373+
1374+
#[test]
1375+
fn test_is_retriable_error_rpc_tx_errors_not_retriable() {
1376+
// Transaction-level messages that should NOT be retriable regardless of code
1377+
let non_retriable_messages = vec![
1378+
"Transaction nonce too low",
1379+
"nonce too low",
1380+
"nonce is too low",
1381+
"already known",
1382+
"known transaction",
1383+
"replacement transaction underpriced",
1384+
"same hash was already imported",
1385+
];
1386+
1387+
// Messages that should remain retriable (generic/unrelated)
1388+
let retriable_messages = vec![
1389+
"Internal error",
1390+
"",
1391+
// "unknown transaction" must NOT false-positive on "known transaction"
1392+
"Unknown transaction status",
1393+
"Resource unavailable",
1394+
];
1395+
1396+
// Both -32603 and -32002 should behave the same way for tx-level messages
1397+
for code in [-32603, -32002] {
1398+
for message in &non_retriable_messages {
1399+
let error = ProviderError::RpcErrorCode {
1400+
code,
1401+
message: message.to_string(),
1402+
};
1403+
assert!(
1404+
!is_retriable_error(&error),
1405+
"{code} with message {message:?} should NOT be retriable"
1406+
);
1407+
}
1408+
1409+
for message in &retriable_messages {
1410+
let error = ProviderError::RpcErrorCode {
1411+
code,
1412+
message: message.to_string(),
1413+
};
1414+
assert!(
1415+
is_retriable_error(&error),
1416+
"{code} with message {message:?} should be retriable"
1417+
);
1418+
}
1419+
}
1420+
}
13231421
}

src/services/provider/retry.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1948,4 +1948,76 @@ mod tests {
19481948
selected.len()
19491949
);
19501950
}
1951+
1952+
#[tokio::test]
1953+
#[serial]
1954+
async fn test_retry_rpc_call_non_retriable_rpc_error_no_failover() {
1955+
use crate::services::provider::{
1956+
is_retriable_error, should_mark_provider_failed, ProviderError,
1957+
};
1958+
1959+
RpcHealthStore::instance().clear_all();
1960+
1961+
let url1 = "http://localhost:9977";
1962+
let url2 = "http://localhost:9976";
1963+
let configs = vec![
1964+
RpcConfig::new(url1.to_string()),
1965+
RpcConfig::new(url2.to_string()),
1966+
];
1967+
let selector = RpcSelector::new(configs, 1, 60, 60).expect("Failed to create selector");
1968+
1969+
let attempts = Arc::new(AtomicU8::new(0));
1970+
let attempts_clone = attempts.clone();
1971+
1972+
let provider_initializer =
1973+
|_url: &str| -> Result<String, ProviderError> { Ok("mock_provider".to_string()) };
1974+
1975+
// Simulate the exact error a Monad-style provider returns:
1976+
// JSON-RPC -32603 with "nonce too low" in the message
1977+
let operation = move |_provider: String| {
1978+
let attempts = attempts_clone.clone();
1979+
async move {
1980+
attempts.fetch_add(1, AtomicOrdering::SeqCst);
1981+
Err::<i32, ProviderError>(ProviderError::RpcErrorCode {
1982+
code: -32603,
1983+
message: "nonce too low".to_string(),
1984+
})
1985+
}
1986+
};
1987+
1988+
let config = RetryConfig::new(3, 1, 0, 0);
1989+
1990+
let result = retry_rpc_call(
1991+
&selector,
1992+
"test_operation",
1993+
is_retriable_error,
1994+
should_mark_provider_failed,
1995+
provider_initializer,
1996+
operation,
1997+
Some(config),
1998+
)
1999+
.await;
2000+
2001+
assert!(result.is_err(), "Expected Err result but got: {result:?}");
2002+
assert_eq!(
2003+
attempts.load(AtomicOrdering::SeqCst),
2004+
1,
2005+
"Operation should be called exactly once (no retries)"
2006+
);
2007+
2008+
// Verify neither provider was marked as failed in the health store
2009+
let health = RpcHealthStore::instance();
2010+
let meta1 = health.get_metadata(url1);
2011+
let meta2 = health.get_metadata(url2);
2012+
assert!(
2013+
meta1.failure_timestamps.is_empty(),
2014+
"Provider 1 should have 0 failures, got: {}",
2015+
meta1.failure_timestamps.len()
2016+
);
2017+
assert!(
2018+
meta2.failure_timestamps.is_empty(),
2019+
"Provider 2 should have 0 failures, got: {}",
2020+
meta2.failure_timestamps.len()
2021+
);
2022+
}
19512023
}

0 commit comments

Comments
 (0)