-
Notifications
You must be signed in to change notification settings - Fork 26
feat: failover for eth rpcs #217
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: master
Are you sure you want to change the base?
Changes from 88 commits
d628cac
fed8b0f
6d48af2
aa09ebc
b490ae5
806907b
3532968
7fff72e
ca56b27
10d81a4
4ded54a
fc550a6
c221709
d9a1819
97cb0ff
0318913
f05db45
c0e6e55
4f58a56
bdad767
1ea96f4
ffca714
8aa7b3d
086c06b
580669d
584dc7c
cb79a53
8131fe4
cb4b54d
871ba18
7b207dd
af370ac
d469d78
0ad90fc
548c1c7
b020580
bb1b69b
5cb9127
f81b8ca
8b9d4b7
e43e2b5
0df3e8b
f34e149
76a575e
737640f
7f25e96
07cc3dd
aa42d4d
3c34e67
d594afb
3cbf396
79ee807
818efcc
4a20fa2
ab76f76
e56f6a6
eb7ec2d
7d3270b
9485ab3
661f8ac
3d3b59c
eac6f19
00073f6
2c4df07
24425e0
6fa14ca
6d1a00f
336226d
cca73d1
2a67aa1
2df2440
fa4e0bd
2a02cca
7c17a6c
3db2806
9437370
d660432
6f38c01
59b4c6e
33e6074
f08dbe7
39fb050
9c6fb1a
19e0a6b
2229f5a
080cb43
8d26bcb
ebad1d2
1c7c56c
70316e5
b5cf1f3
69df084
d7b9e8c
eb64834
75fa395
0a4c61f
941911a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,10 +7,13 @@ use std::{ | |
|
|
||
| use byte_unit::Byte; | ||
| use clap::{Parser, command}; | ||
| use eth::{Address, L1Keys}; | ||
| use eth::{Address, Endpoint, L1Keys}; | ||
| use fuel_block_committer_encoding::bundle::CompressionLevel; | ||
| use serde::Deserialize; | ||
| use services::state_committer::{AlgoConfig, FeeMultiplierRange, FeeThresholds, SmaPeriods}; | ||
| use services::{ | ||
| state_committer::{AlgoConfig, FeeMultiplierRange, FeeThresholds, SmaPeriods}, | ||
| types::NonEmpty, | ||
| }; | ||
| use storage::DbConfig; | ||
| use url::Url; | ||
|
|
||
|
|
@@ -79,13 +82,33 @@ impl Config { | |
| }; | ||
| Ok(algo_config) | ||
| } | ||
|
|
||
| pub fn eth_provider_health_thresholds(&self) -> eth::ProviderHealthThresholds { | ||
| eth::ProviderHealthThresholds { | ||
| transient_error_threshold: self.eth.failover.transient_error_threshold, | ||
| tx_failure_threshold: self.eth.failover.tx_failure_threshold, | ||
| tx_failure_time_window: self.eth.failover.tx_failure_time_window, | ||
| } | ||
| } | ||
|
|
||
| pub fn eth_tx_config(&self) -> eth::TxConfig { | ||
| eth::TxConfig { | ||
| tx_max_fee: u128::from(self.app.tx_fees.max), | ||
| send_tx_request_timeout: self.app.send_tx_request_timeout, | ||
| acceptable_priority_fee_percentage: eth::AcceptablePriorityFeePercentages::new( | ||
| self.app.tx_fees.min_reward_perc, | ||
| self.app.tx_fees.max_reward_perc, | ||
| ) | ||
| .expect("already validated via `validate` in main"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opinion: Ideally we'd leverage the type system to guarantee this property, as validation logic is prone to change over time. While I think this is fine for now, in similar situations I'd consider defining a |
||
| } | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct Fuel { | ||
| /// Fuel-core GraphQL endpoint URL. | ||
| /// Fuel-core endpoint URL. | ||
| #[serde(deserialize_with = "parse_url")] | ||
| pub graphql_endpoint: Url, | ||
| pub endpoint: Url, | ||
| /// Number of concurrent requests. | ||
| pub num_buffered_requests: NonZeroU32, | ||
| } | ||
|
|
@@ -94,11 +117,51 @@ pub struct Fuel { | |
| pub struct Eth { | ||
| /// L1 keys for state contract calls and postings. | ||
| pub l1_keys: L1Keys, | ||
| /// Ethereum RPC endpoint URL. | ||
| #[serde(deserialize_with = "parse_url")] | ||
| pub rpc: Url, | ||
| /// Multiple Ethereum RPC endpoints as a JSON array. | ||
| /// Format: '[{"name":"main","url":"wss://ethereum.example.com"}, {"name":"backup","url":"wss://backup.example.com"}]' | ||
| #[serde(deserialize_with = "parse_endpoints")] | ||
| pub endpoints: NonEmpty<Endpoint>, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice use of |
||
| /// Ethereum address of the fuel chain state contract. | ||
| pub state_contract_address: Address, | ||
| /// Configuration for RPC failover behavior | ||
| pub failover: FailoverConfig, | ||
| } | ||
|
|
||
| /// Configuration for managing RPC failover behavior | ||
| #[derive(Debug, Clone, Deserialize)] | ||
| pub struct FailoverConfig { | ||
| /// Maximum number of transient errors before considering a provider unhealthy | ||
| pub transient_error_threshold: usize, | ||
| /// Maximum number of transaction failures within the specified time window before marking a provider as unhealthy. | ||
| pub tx_failure_threshold: usize, | ||
| /// Time window to track transaction failures in. | ||
| /// Format: Human-readable duration (e.g., `5m`, `30m`) | ||
| #[serde(deserialize_with = "human_readable_duration")] | ||
| pub tx_failure_time_window: Duration, | ||
| } | ||
|
|
||
| fn parse_endpoints<'de, D>(deserializer: D) -> Result<NonEmpty<Endpoint>, D::Error> | ||
| where | ||
| D: serde::Deserializer<'de>, | ||
| { | ||
| let value: String = Deserialize::deserialize(deserializer)?; | ||
| if value.is_empty() { | ||
| return Err(serde::de::Error::custom( | ||
| "Ethereum endpoints cannot be empty", | ||
| )); | ||
| } | ||
|
|
||
| let configs: Vec<Endpoint> = serde_json::from_str(&value).map_err(|err| { | ||
| serde::de::Error::custom(format!("Invalid JSON format for Ethereum endpoints: {err}",)) | ||
| })?; | ||
|
|
||
| let Some(configs) = NonEmpty::from_vec(configs) else { | ||
| return Err(serde::de::Error::custom( | ||
| "At least one Ethereum endpoint must be configured", | ||
| )); | ||
| }; | ||
|
|
||
| Ok(configs) | ||
| } | ||
|
|
||
| fn parse_url<'de, D>(deserializer: D) -> Result<Url, D::Error> | ||
|
|
@@ -187,7 +250,7 @@ pub struct BundleConfig { | |
| /// Time to wait for additional blocks before starting bundling. | ||
| /// | ||
| /// This timeout starts from the last time a bundle was created or from app startup. | ||
| /// Bundling will occur when this timeout expires, even if byte or block thresholds aren’t met. | ||
| /// Bundling will occur when this timeout expires, even if byte or block thresholds aren't met. | ||
| #[serde(deserialize_with = "human_readable_duration")] | ||
| pub accumulation_timeout: Duration, | ||
|
|
||
|
|
@@ -289,7 +352,6 @@ where | |
| #[derive(Debug, Clone)] | ||
| pub struct Internal { | ||
| pub fuel_errors_before_unhealthy: usize, | ||
| pub eth_errors_before_unhealthy: usize, | ||
| pub balance_update_interval: Duration, | ||
| pub cost_request_limit: usize, | ||
| pub l1_blocks_cached_for_fee_metrics_tracker: usize, | ||
|
|
@@ -319,7 +381,6 @@ impl Default for Internal { | |
| const ETH_BLOCKS_PER_DAY: usize = 24 * 3600 / ETH_BLOCK_TIME; | ||
| Self { | ||
| fuel_errors_before_unhealthy: 3, | ||
| eth_errors_before_unhealthy: 3, | ||
| balance_update_interval: Duration::from_secs(10), | ||
| cost_request_limit: 1000, | ||
| l1_blocks_cached_for_fee_metrics_tracker: ETH_BLOCKS_PER_DAY, | ||
|
|
@@ -352,3 +413,94 @@ pub fn parse() -> crate::errors::Result<Config> { | |
|
|
||
| Ok(config.try_deserialize()?) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use serde::de::value::{Error as SerdeError, StringDeserializer}; | ||
| use serde_json::json; | ||
| use services::types::nonempty; | ||
|
|
||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn test_parse_endpoints() { | ||
| // given | ||
| let valid_configs = json!([ | ||
| {"name": "main", "url": "https://ethereum.example.com"}, | ||
| {"name": "backup", "url": "https://backup.example.com"} | ||
| ]) | ||
| .to_string(); | ||
|
|
||
| let expected_configs = nonempty![ | ||
| Endpoint { | ||
| name: "main".to_string(), | ||
| url: Url::parse("https://ethereum.example.com").unwrap(), | ||
| }, | ||
| Endpoint { | ||
| name: "backup".to_string(), | ||
| url: Url::parse("https://backup.example.com").unwrap(), | ||
| }, | ||
| ]; | ||
|
|
||
| let deserializer = StringDeserializer::<SerdeError>::new(valid_configs); | ||
|
|
||
| // when | ||
| let result = parse_endpoints(deserializer); | ||
|
|
||
| // then | ||
| let configs = result.unwrap(); | ||
| assert_eq!(configs, expected_configs); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_endpoints_invalid_json() { | ||
| // given | ||
| let invalid_json = "not a valid json"; | ||
|
|
||
| let deserializer = StringDeserializer::<SerdeError>::new(invalid_json.to_string()); | ||
|
|
||
| // when | ||
| let result = parse_endpoints(deserializer); | ||
|
|
||
| // then | ||
| let err_msg = result | ||
| .expect_err("should have failed since the json is invalid") | ||
| .to_string(); | ||
| assert!(err_msg.contains("Invalid JSON format")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_endpoints_empty_array() { | ||
| // given | ||
| let empty_array = json!([]).to_string(); | ||
|
|
||
| let deserializer = StringDeserializer::<SerdeError>::new(empty_array); | ||
|
|
||
| // when | ||
| let result = parse_endpoints(deserializer); | ||
|
|
||
| // then | ||
| let err_msg = result | ||
| .expect_err("should have failed since the array is empty") | ||
| .to_string(); | ||
| assert!(err_msg.contains("At least one Ethereum endpoint must be configured")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_parse_endpoints_invalid_url() { | ||
| // given | ||
| let invalid_url = json!([ | ||
| {"name": "main", "url": "not a valid url"} | ||
| ]) | ||
| .to_string(); | ||
|
|
||
| let deserializer = StringDeserializer::<SerdeError>::new(invalid_url); | ||
|
|
||
| // when | ||
| let result = parse_endpoints(deserializer); | ||
|
|
||
| // then | ||
| let err_msg = result.expect_err("because url was not valid").to_string(); | ||
| assert!(err_msg.contains("Invalid JSON format")); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.