11use alloy:: { ens:: ProviderEnsExt , primitives:: Address , providers:: ProviderBuilder } ;
22use reqwest:: Client ;
33use serde:: { Deserialize , Serialize } ;
4- use std:: { str:: FromStr , time:: Duration } ;
4+ use std:: { str:: FromStr , sync :: Arc , time:: Duration } ;
55use thiserror:: Error ;
6+ use tokio:: sync:: Semaphore ;
67
78use 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
8287impl 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