@@ -72,8 +72,11 @@ pub struct DomainFronter {
7272 cache : Arc < ResponseCache > ,
7373 inflight : Arc < Mutex < HashMap < String , broadcast:: Sender < Vec < u8 > > > > > ,
7474 coalesced : AtomicU64 ,
75+ blacklist : Arc < std:: sync:: Mutex < HashMap < String , Instant > > > ,
7576}
7677
78+ const BLACKLIST_COOLDOWN_SECS : u64 = 600 ;
79+
7780/// Request payload sent to Apps Script (single, non-batch).
7881#[ derive( Serialize ) ]
7982struct RelayRequest < ' a > {
@@ -134,6 +137,7 @@ impl DomainFronter {
134137 cache : Arc :: new ( ResponseCache :: with_default ( ) ) ,
135138 inflight : Arc :: new ( Mutex :: new ( HashMap :: new ( ) ) ) ,
136139 coalesced : AtomicU64 :: new ( 0 ) ,
140+ blacklist : Arc :: new ( std:: sync:: Mutex :: new ( HashMap :: new ( ) ) ) ,
137141 } )
138142 }
139143
@@ -145,9 +149,38 @@ impl DomainFronter {
145149 self . coalesced . load ( Ordering :: Relaxed )
146150 }
147151
148- fn next_script_id ( & self ) -> & str {
149- let idx = self . script_idx . fetch_add ( 1 , Ordering :: Relaxed ) ;
150- & self . script_ids [ idx % self . script_ids . len ( ) ]
152+ fn next_script_id ( & self ) -> String {
153+ let n = self . script_ids . len ( ) ;
154+ let mut bl = self . blacklist . lock ( ) . unwrap ( ) ;
155+ let now = Instant :: now ( ) ;
156+ bl. retain ( |_, until| * until > now) ;
157+
158+ for _ in 0 ..n {
159+ let idx = self . script_idx . fetch_add ( 1 , Ordering :: Relaxed ) ;
160+ let sid = & self . script_ids [ idx % n] ;
161+ if !bl. contains_key ( sid) {
162+ return sid. clone ( ) ;
163+ }
164+ }
165+ // All blacklisted: pick whichever comes off cooldown soonest.
166+ if let Some ( ( sid, _) ) = bl. iter ( ) . min_by_key ( |( _, t) | * * t) {
167+ let sid = sid. clone ( ) ;
168+ bl. remove ( & sid) ;
169+ return sid;
170+ }
171+ self . script_ids [ 0 ] . clone ( )
172+ }
173+
174+ fn blacklist_script ( & self , script_id : & str , reason : & str ) {
175+ let until = Instant :: now ( ) + Duration :: from_secs ( BLACKLIST_COOLDOWN_SECS ) ;
176+ let mut bl = self . blacklist . lock ( ) . unwrap ( ) ;
177+ bl. insert ( script_id. to_string ( ) , until) ;
178+ tracing:: warn!(
179+ "blacklisted script {} for {}s: {}" ,
180+ mask_script_id( script_id) ,
181+ BLACKLIST_COOLDOWN_SECS ,
182+ reason
183+ ) ;
151184 }
152185
153186 async fn open ( & self ) -> Result < PooledStream , FronterError > {
@@ -305,7 +338,7 @@ impl DomainFronter {
305338 body : & [ u8 ] ,
306339 ) -> Result < Vec < u8 > , FronterError > {
307340 let payload = self . build_payload_json ( method, url, headers, body) ?;
308- let script_id = self . next_script_id ( ) . to_string ( ) ;
341+ let script_id = self . next_script_id ( ) ;
309342 let path = format ! ( "/macros/s/{}/exec" , script_id) ;
310343
311344 let mut entry = self . acquire ( ) . await ?;
@@ -367,17 +400,29 @@ impl DomainFronter {
367400 }
368401
369402 if status != 200 {
403+ let body_txt = String :: from_utf8_lossy ( & resp_body)
404+ . chars ( )
405+ . take ( 200 )
406+ . collect :: < String > ( ) ;
407+ if should_blacklist ( status, & body_txt) {
408+ self . blacklist_script ( & script_id, & format ! ( "HTTP {}" , status) ) ;
409+ }
370410 return Err ( FronterError :: Relay ( format ! (
371411 "Apps Script HTTP {}: {}" ,
372- status,
373- String :: from_utf8_lossy( & resp_body)
374- . chars( )
375- . take( 200 )
376- . collect:: <String >( )
412+ status, body_txt
377413 ) ) ) ;
378414 }
379- let bytes = parse_relay_json ( & resp_body) ?;
380- Ok :: < _ , FronterError > ( ( bytes, true ) )
415+ match parse_relay_json ( & resp_body) {
416+ Ok ( bytes) => Ok :: < _ , FronterError > ( ( bytes, true ) ) ,
417+ Err ( e) => {
418+ if let FronterError :: Relay ( ref msg) = e {
419+ if looks_like_quota_error ( msg) {
420+ self . blacklist_script ( & script_id, msg) ;
421+ }
422+ }
423+ Err ( e)
424+ }
425+ }
381426 }
382427 }
383428 } ;
@@ -727,6 +772,32 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
727772 Ok ( out)
728773}
729774
775+ fn should_blacklist ( status : u16 , body : & str ) -> bool {
776+ if status == 429 || status == 403 {
777+ return true ;
778+ }
779+ looks_like_quota_error ( body)
780+ }
781+
782+ fn looks_like_quota_error ( msg : & str ) -> bool {
783+ let lower = msg. to_ascii_lowercase ( ) ;
784+ lower. contains ( "quota" )
785+ || lower. contains ( "daily limit" )
786+ || lower. contains ( "rate limit" )
787+ || lower. contains ( "too many times" )
788+ || lower. contains ( "service invoked" )
789+ }
790+
791+ fn mask_script_id ( id : & str ) -> String {
792+ let n = id. chars ( ) . count ( ) ;
793+ if n <= 8 {
794+ return "***" . into ( ) ;
795+ }
796+ let head: String = id. chars ( ) . take ( 4 ) . collect ( ) ;
797+ let tail: String = id. chars ( ) . skip ( n - 4 ) . collect ( ) ;
798+ format ! ( "{}...{}" , head, tail)
799+ }
800+
730801fn value_to_header_str ( v : & Value ) -> Option < String > {
731802 match v {
732803 Value :: String ( s) => Some ( s. clone ( ) ) ,
@@ -894,6 +965,23 @@ mod tests {
894965 assert ! ( matches!( err, FronterError :: Relay ( _) ) ) ;
895966 }
896967
968+ #[ test]
969+ fn blacklist_heuristics ( ) {
970+ assert ! ( should_blacklist( 429 , "" ) ) ;
971+ assert ! ( should_blacklist( 403 , "quota" ) ) ;
972+ assert ! ( should_blacklist( 500 , "Service invoked too many times per day: urlfetch" ) ) ;
973+ assert ! ( !should_blacklist( 200 , "" ) ) ;
974+ assert ! ( !should_blacklist( 502 , "bad gateway" ) ) ;
975+ assert ! ( looks_like_quota_error( "Exception: Service invoked too many times per day" ) ) ;
976+ assert ! ( !looks_like_quota_error( "bad url" ) ) ;
977+ }
978+
979+ #[ test]
980+ fn mask_script_id_hides_middle ( ) {
981+ assert_eq ! ( mask_script_id( "short" ) , "***" ) ;
982+ assert_eq ! ( mask_script_id( "AKfycbx1234567890abcdef" ) , "AKfy...cdef" ) ;
983+ }
984+
897985 #[ test]
898986 fn parse_relay_array_set_cookie ( ) {
899987 let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"# ;
0 commit comments