Skip to content

Commit 31dfa81

Browse files
feat(pbs): add retry limit for validator registration (#316) (#322)
1 parent a417258 commit 31dfa81

File tree

8 files changed

+162
-13
lines changed

8 files changed

+162
-13
lines changed

crates/common/src/config/pbs.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ use crate::{
2727
},
2828
pbs::{
2929
BuilderEventPublisher, DefaultTimeout, RelayClient, RelayEntry, DEFAULT_PBS_PORT,
30-
LATE_IN_SLOT_TIME_MS,
30+
LATE_IN_SLOT_TIME_MS, REGISTER_VALIDATOR_RETRY_LIMIT,
3131
},
3232
types::{Chain, Jwt, ModuleId},
3333
utils::{
34-
as_eth_str, default_bool, default_host, default_u16, default_u256, default_u64, WEI_PER_ETH,
34+
as_eth_str, default_bool, default_host, default_u16, default_u256, default_u32,
35+
default_u64, WEI_PER_ETH,
3536
},
3637
};
3738

@@ -122,6 +123,9 @@ pub struct PbsConfig {
122123
pub extra_validation_enabled: bool,
123124
/// Execution Layer RPC url to use for extra validation
124125
pub rpc_url: Option<Url>,
126+
/// Maximum number of retries for validator registration request per relay
127+
#[serde(default = "default_u32::<REGISTER_VALIDATOR_RETRY_LIMIT>")]
128+
pub register_validator_retry_limit: u32,
125129
}
126130

127131
impl PbsConfig {
@@ -140,6 +144,10 @@ impl PbsConfig {
140144
self.timeout_get_header_ms < self.late_in_slot_time_ms,
141145
"timeout_get_header_ms must be less than late_in_slot_time_ms"
142146
);
147+
ensure!(
148+
self.register_validator_retry_limit > 0,
149+
"register_validator_retry_limit must be greater than 0"
150+
);
143151

144152
ensure!(
145153
self.min_bid_wei < U256::from(WEI_PER_ETH),

crates/common/src/pbs/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ impl DefaultTimeout {
3030
}
3131

3232
pub const LATE_IN_SLOT_TIME_MS: u64 = 2000;
33+
34+
// Maximum number of retries for validator registration request per relay
35+
pub const REGISTER_VALIDATOR_RETRY_LIMIT: u32 = 3;

crates/common/src/pbs/error.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,18 @@ impl PbsError {
3737

3838
/// Whether the error is retryable in requests to relays
3939
pub fn should_retry(&self) -> bool {
40-
matches!(self, PbsError::RelayResponse { .. } | PbsError::Reqwest { .. })
40+
match self {
41+
PbsError::Reqwest(err) => {
42+
// Retry on timeout or connection error
43+
err.is_timeout() || err.is_connect()
44+
}
45+
PbsError::RelayResponse { code, .. } => match *code {
46+
500..509 => true, // Retry on server errors
47+
400 | 429 => false, // Do not retry if rate limited or bad request
48+
_ => false,
49+
},
50+
_ => false,
51+
}
4152
}
4253
}
4354

crates/common/src/utils.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ pub const fn default_u64<const U: u64>() -> u64 {
137137
U
138138
}
139139

140+
pub const fn default_u32<const U: u32>() -> u32 {
141+
U
142+
}
143+
140144
pub const fn default_u16<const U: u16>() -> u16 {
141145
U
142146
}

crates/pbs/src/mev_boost/register_validator.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub async fn register_validator<S: BuilderApiState>(
4343
relay.clone(),
4444
send_headers.clone(),
4545
state.pbs_config().timeout_register_validator_ms,
46+
state.pbs_config().register_validator_retry_limit,
4647
)
4748
.in_current_span(),
4849
));
@@ -54,6 +55,7 @@ pub async fn register_validator<S: BuilderApiState>(
5455
relay.clone(),
5556
send_headers.clone(),
5657
state.pbs_config().timeout_register_validator_ms,
58+
state.pbs_config().register_validator_retry_limit,
5759
)
5860
.in_current_span(),
5961
));
@@ -85,6 +87,7 @@ async fn send_register_validator_with_timeout(
8587
relay: RelayClient,
8688
headers: HeaderMap,
8789
timeout_ms: u64,
90+
retry_limit: u32,
8891
) -> Result<(), PbsError> {
8992
let url = relay.register_validator_url()?;
9093
let mut remaining_timeout_ms = timeout_ms;
@@ -106,6 +109,14 @@ async fn send_register_validator_with_timeout(
106109
Ok(_) => return Ok(()),
107110

108111
Err(err) if err.should_retry() => {
112+
retry += 1;
113+
if retry >= retry_limit {
114+
error!(
115+
relay_id = relay.id.as_str(),
116+
retry, "reached retry limit for validator registration"
117+
);
118+
return Err(err);
119+
}
109120
tokio::time::sleep(backoff).await;
110121
backoff += Duration::from_millis(250);
111122

@@ -119,8 +130,6 @@ async fn send_register_validator_with_timeout(
119130

120131
Err(err) => return Err(err),
121132
};
122-
123-
retry += 1;
124133
}
125134
}
126135

tests/src/mock_relay.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::{
22
net::SocketAddr,
33
sync::{
44
atomic::{AtomicU64, Ordering},
5-
Arc,
5+
Arc, RwLock,
66
},
77
};
88

@@ -48,6 +48,7 @@ pub struct MockRelayState {
4848
received_get_status: Arc<AtomicU64>,
4949
received_register_validator: Arc<AtomicU64>,
5050
received_submit_block: Arc<AtomicU64>,
51+
response_override: RwLock<Option<StatusCode>>,
5152
}
5253

5354
impl MockRelayState {
@@ -66,6 +67,9 @@ impl MockRelayState {
6667
pub fn large_body(&self) -> bool {
6768
self.large_body
6869
}
70+
pub fn set_response_override(&self, status: StatusCode) {
71+
*self.response_override.write().unwrap() = Some(status);
72+
}
6973
}
7074

7175
impl MockRelayState {
@@ -78,6 +82,7 @@ impl MockRelayState {
7882
received_get_status: Default::default(),
7983
received_register_validator: Default::default(),
8084
received_submit_block: Default::default(),
85+
response_override: RwLock::new(None),
8186
}
8287
}
8388

@@ -130,7 +135,12 @@ async fn handle_register_validator(
130135
) -> impl IntoResponse {
131136
state.received_register_validator.fetch_add(1, Ordering::Relaxed);
132137
debug!("Received {} registrations", validators.len());
133-
StatusCode::OK
138+
139+
if let Some(status) = state.response_override.read().unwrap().as_ref() {
140+
return (*status).into_response();
141+
}
142+
143+
StatusCode::OK.into_response()
134144
}
135145

136146
async fn handle_submit_block(State(state): State<Arc<MockRelayState>>) -> Response {

tests/src/utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub fn get_pbs_static_config(port: u16) -> PbsConfig {
7272
late_in_slot_time_ms: u64::MAX,
7373
extra_validation_enabled: false,
7474
rpc_url: None,
75+
register_validator_retry_limit: u32::MAX,
7576
}
7677
}
7778

tests/tests/pbs_post_validators.rs

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async fn test_register_validators() -> Result<()> {
4646
"message": {
4747
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4848
"gas_limit": "100000",
49-
"timestamp": "1000000",
49+
"timestamp": "1000000",
5050
"pubkey": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
5151
},
5252
"signature": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
@@ -93,7 +93,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
9393
"message": {
9494
"fee_recipient": "0xaa",
9595
"gas_limit": "100000",
96-
"timestamp": "1000000",
96+
"timestamp": "1000000",
9797
"pubkey": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
9898
},
9999
"signature": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
@@ -115,7 +115,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
115115
"message": {
116116
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
117117
"gas_limit": "100000",
118-
"timestamp": "1000000",
118+
"timestamp": "1000000",
119119
"pubkey": "0xbbb"
120120
},
121121
"signature": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
@@ -137,7 +137,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
137137
"message": {
138138
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
139139
"gas_limit": "100000",
140-
"timestamp": "1000000",
140+
"timestamp": "1000000",
141141
"pubkey": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
142142
},
143143
"signature": "0xcccc"
@@ -159,7 +159,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
159159
"message": {
160160
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
161161
"gas_limit": "10000000000000000000000000000000000000000000000000000000",
162-
"timestamp": "1000000",
162+
"timestamp": "1000000",
163163
"pubkey": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
164164
},
165165
"signature": "0xcccc"
@@ -181,7 +181,7 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
181181
"message": {
182182
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
183183
"gas_limit": "1000000",
184-
"timestamp": "10000000000000000000000000000000000000000000000000000000",
184+
"timestamp": "10000000000000000000000000000000000000000000000000000000",
185185
"pubkey": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
186186
},
187187
"signature": "0xcccc"
@@ -201,3 +201,106 @@ async fn test_register_validators_returns_422_if_request_is_malformed() -> Resul
201201
assert_eq!(mock_state.received_register_validator(), 0);
202202
Ok(())
203203
}
204+
205+
#[tokio::test]
206+
async fn test_register_validators_does_not_retry_on_429() -> Result<()> {
207+
setup_test_env();
208+
let signer = random_secret();
209+
let pubkey: BlsPublicKey = blst_pubkey_to_alloy(&signer.sk_to_pk()).into();
210+
211+
let chain = Chain::Holesky;
212+
let pbs_port = 4200;
213+
214+
// Set up mock relay state and override response to 429
215+
let mock_state = Arc::new(MockRelayState::new(chain, signer));
216+
mock_state.set_response_override(StatusCode::TOO_MANY_REQUESTS);
217+
218+
// Run a mock relay
219+
let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?];
220+
tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1));
221+
222+
// Run the PBS service
223+
let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays);
224+
let state = PbsState::new(config);
225+
tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone()));
226+
227+
// Leave some time to start servers
228+
tokio::time::sleep(Duration::from_millis(100)).await;
229+
230+
let mock_validator = MockValidator::new(pbs_port)?;
231+
info!("Sending register validator to test 429 response");
232+
233+
let registration: ValidatorRegistration = serde_json::from_str(
234+
r#"{
235+
"message": {
236+
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
237+
"gas_limit": "100000",
238+
"timestamp": "1000000",
239+
"pubkey": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
240+
},
241+
"signature": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
242+
}"#,
243+
)?;
244+
245+
let registrations = vec![registration];
246+
let res = mock_validator.do_register_custom_validators(registrations).await?;
247+
248+
// Should only be called once (no retry)
249+
assert_eq!(mock_state.received_register_validator(), 1);
250+
// Expected to return 429 status code
251+
// But it returns `No relay passed register_validator successfully` with 502
252+
// status code
253+
assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
254+
255+
Ok(())
256+
}
257+
258+
#[tokio::test]
259+
async fn test_register_validators_retries_on_500() -> Result<()> {
260+
setup_test_env();
261+
let signer = random_secret();
262+
let pubkey: BlsPublicKey = blst_pubkey_to_alloy(&signer.sk_to_pk()).into();
263+
264+
let chain = Chain::Holesky;
265+
let pbs_port = 4300;
266+
267+
// Set up internal mock relay with 500 response override
268+
let mock_state = Arc::new(MockRelayState::new(chain, signer));
269+
mock_state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); // 500
270+
271+
let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?];
272+
tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1));
273+
274+
// Set retry limit to 3
275+
let mut pbs_config = get_pbs_static_config(pbs_port);
276+
pbs_config.register_validator_retry_limit = 3;
277+
278+
let config = to_pbs_config(chain, pbs_config, relays);
279+
let state = PbsState::new(config);
280+
tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone()));
281+
282+
tokio::time::sleep(Duration::from_millis(100)).await;
283+
284+
let mock_validator = MockValidator::new(pbs_port)?;
285+
info!("Sending register validator to test retry on 500");
286+
287+
let registration: ValidatorRegistration = serde_json::from_str(
288+
r#"{
289+
"message": {
290+
"fee_recipient": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
291+
"gas_limit": "100000",
292+
"timestamp": "1000000",
293+
"pubkey": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
294+
},
295+
"signature": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
296+
}"#,
297+
)?;
298+
299+
let registrations = vec![registration];
300+
let _ = mock_validator.do_register_custom_validators(registrations).await;
301+
302+
// Should retry 3 times (0, 1, 2) → total 3 calls
303+
assert_eq!(mock_state.received_register_validator(), 3);
304+
305+
Ok(())
306+
}

0 commit comments

Comments
 (0)