Skip to content

Commit 607a575

Browse files
committed
feat: finish adding rate limit
1 parent 2e8e85e commit 607a575

5 files changed

Lines changed: 82 additions & 3 deletions

File tree

config/default.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,4 @@ etherscan_base_url = "https://api.etherscan.io/v2/api?chainid=1"
8989
infura_api_key = "change-me"
9090
infura_base_url = "https://mainnet.infura.io/v3"
9191
etherscan_calls_per_sec = 3
92+
max_concurrent_requests = 1

config/example.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ etherscan_base_url = "https://api.etherscan.io/v2/api?chainid=1"
9999
infura_api_key = "change-me"
100100
infura_base_url = "https://mainnet.infura.io/v3"
101101
etherscan_calls_per_sec = 3
102+
max_concurrent_requests = 1
102103

103104
# Example environment variable overrides:
104105
# TASKMASTER_BLOCKCHAIN__NODE_URL="ws://remote-node:9944"

config/test.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,4 @@ etherscan_base_url = "https://api.etherscan.io/v2/api?chainid=1"
8989
infura_api_key = "change-me"
9090
infura_base_url = "https://mainnet.infura.io/v3"
9191
etherscan_calls_per_sec = 3
92+
max_concurrent_requests = 1

src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,8 @@ pub struct RiskCheckerConfig {
108108
pub etherscan_base_url: String,
109109
pub infura_api_key: String,
110110
pub infura_base_url: String,
111-
/// Maximum Etherscan API calls per second. Used to space out sequential
112-
/// calls and stay within the free-tier limit (default: 3).
113111
pub etherscan_calls_per_sec: u32,
112+
pub max_concurrent_requests: usize,
114113
}
115114

116115
impl Config {
@@ -260,6 +259,7 @@ impl Default for Config {
260259
infura_api_key: "change-me".to_string(),
261260
infura_base_url: "https://mainnet.infura.io/v3".to_string(),
262261
etherscan_calls_per_sec: 3,
262+
max_concurrent_requests: 1,
263263
},
264264
}
265265
}

src/services/risk_checker_service.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use alloy::{ens::ProviderEnsExt, primitives::Address, providers::ProviderBuilder};
22
use reqwest::Client;
33
use serde::{Deserialize, Serialize};
4-
use std::{str::FromStr, time::Duration};
4+
use std::{str::FromStr, sync::Arc, time::Duration};
55
use thiserror::Error;
6+
use tokio::sync::Semaphore;
67

78
use crate::config::RiskCheckerConfig;
89

@@ -77,6 +78,10 @@ pub struct RiskCheckerService {
7778
infura_rpc_url: String,
7879
/// Minimum delay between consecutive Etherscan calls to stay within the rate limit.
7980
etherscan_call_delay: Duration,
81+
/// Global semaphore that caps the number of concurrent `generate_report`
82+
/// executions across all inbound requests, preventing outbound Etherscan
83+
/// fan-out from overwhelming the API quota.
84+
concurrency_limiter: Arc<Semaphore>,
8085
}
8186

8287
impl RiskCheckerService {
@@ -94,12 +99,15 @@ impl RiskCheckerService {
9499
.build()
95100
.expect("TLS backend should be initialized, or the resolver should load the system configuration.");
96101

102+
let max_concurrent = config.max_concurrent_requests.max(1);
103+
97104
Self {
98105
client,
99106
etherscan_api_key: config.etherscan_api_key.clone(),
100107
etherscan_base_url: config.etherscan_base_url.clone(),
101108
infura_rpc_url,
102109
etherscan_call_delay,
110+
concurrency_limiter: Arc::new(Semaphore::new(max_concurrent)),
103111
}
104112
}
105113

@@ -335,10 +343,19 @@ impl RiskCheckerService {
335343
etherscan_base_url: etherscan_base_url.to_string(),
336344
infura_rpc_url: infura_rpc_url.to_string(),
337345
etherscan_call_delay: Duration::ZERO,
346+
concurrency_limiter: Arc::new(Semaphore::new(1)),
338347
}
339348
}
340349

341350
pub async fn generate_report(&self, input: &str) -> Result<RiskReport, RiskCheckerError> {
351+
// Acquire a concurrency permit before touching Etherscan. If all permits
352+
// are taken the caller gets an immediate RateLimit error rather than
353+
// queuing indefinitely, which protects the outbound API quota.
354+
let _permit = self.concurrency_limiter.try_acquire().map_err(|_| {
355+
tracing::warn!("Risk checker concurrency limit reached; rejecting request");
356+
RiskCheckerError::RateLimit
357+
})?;
358+
342359
let resolution = self.resolve_address_or_ens(input).await?;
343360

344361
let (resolved_address, ens_name) = match resolution {
@@ -881,4 +898,63 @@ mod tests {
881898
// Assert
882899
assert!(matches!(result, Err(RiskCheckerError::RateLimit)));
883900
}
901+
902+
/// Verify that requests beyond the concurrency limit are immediately
903+
/// rejected with RateLimit rather than queuing and hammering Etherscan.
904+
#[tokio::test]
905+
async fn test_generate_report_concurrency_limit_rejects_excess_requests() {
906+
use std::sync::Arc;
907+
use tokio::sync::Barrier;
908+
909+
let mock_server = MockServer::start().await;
910+
let address = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045";
911+
912+
// Respond slowly so the first request holds the permit long enough for
913+
// the second to arrive and be rejected.
914+
Mock::given(method("GET"))
915+
.respond_with(
916+
ResponseTemplate::new(200)
917+
.set_delay(Duration::from_millis(200))
918+
.set_body_json(serde_json::json!({ "status": "0", "message": "NOTOK", "result": "" })),
919+
)
920+
.mount(&mock_server)
921+
.await;
922+
923+
// Limit to exactly 1 concurrent request.
924+
let service = Arc::new(setup_service(&mock_server).await);
925+
926+
// Use a barrier so both tasks start at the same instant.
927+
let barrier = Arc::new(Barrier::new(2));
928+
929+
let svc1 = service.clone();
930+
let b1 = barrier.clone();
931+
let addr = address.to_string();
932+
let t1 = tokio::spawn(async move {
933+
b1.wait().await;
934+
svc1.generate_report(&addr).await
935+
});
936+
937+
let svc2 = service.clone();
938+
let b2 = barrier.clone();
939+
let addr = address.to_string();
940+
let t2 = tokio::spawn(async move {
941+
b2.wait().await;
942+
svc2.generate_report(&addr).await
943+
});
944+
945+
let (r1, r2) = tokio::join!(t1, t2);
946+
let r1 = r1.unwrap();
947+
let r2 = r2.unwrap();
948+
949+
// Exactly one of the two concurrent calls must be rejected with RateLimit.
950+
let rate_limited = [&r1, &r2]
951+
.iter()
952+
.filter(|r| matches!(r, Err(RiskCheckerError::RateLimit)))
953+
.count();
954+
955+
assert_eq!(
956+
rate_limited, 1,
957+
"expected exactly one request to be rate-limited; got r1={r1:?}, r2={r2:?}"
958+
);
959+
}
884960
}

0 commit comments

Comments
 (0)