@@ -42,6 +42,7 @@ use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
4242
4343use crate :: cache:: { cache_key, is_cacheable_method, parse_ttl, ResponseCache } ;
4444use crate :: config:: Config ;
45+ use crate :: quota_tracker:: { QuotaSummary , QuotaTracker } ;
4546
4647#[ derive( Debug , thiserror:: Error ) ]
4748pub enum FronterError {
@@ -420,6 +421,10 @@ pub struct DomainFronter {
420421 auto_blacklist_strikes : u32 ,
421422 auto_blacklist_window : Duration ,
422423 auto_blacklist_cooldown : Duration ,
424+ /// Per-account quota tracker. One bucket per configured script_id,
425+ /// each treated as a separate Google account per the model assumption.
426+ /// Persists to quota_state.json so quota state survives restarts.
427+ quota_tracker : Arc < QuotaTracker > ,
423428 /// Per-batch HTTP timeout. Mirrors `Config::request_timeout_secs`
424429 /// (#430, masterking32 PR #25). Read by `tunnel_client::fire_batch`
425430 /// so a single config field tunes the timeout used everywhere.
@@ -599,6 +604,13 @@ impl DomainFronter {
599604 tls_h1. alpn_protocols = vec ! [ b"http/1.1" . to_vec( ) ] ;
600605 let tls_connector_h1 = TlsConnector :: from ( Arc :: new ( tls_h1) ) ;
601606
607+ // Build quota tracker before script_ids is moved into the struct.
608+ let quota_tracker_arc = Arc :: new ( QuotaTracker :: load (
609+ & script_ids,
610+ config. quota_daily_limit ,
611+ config. quota_safety_buffer ,
612+ ) ) ;
613+
602614 Ok ( Self {
603615 connect_host : config. google_ip . clone ( ) ,
604616 sni_hosts : build_sni_pool_for (
@@ -644,6 +656,7 @@ impl DomainFronter {
644656 auto_blacklist_cooldown : Duration :: from_secs (
645657 config. auto_blacklist_cooldown_secs . clamp ( 1 , 86400 ) ,
646658 ) ,
659+ quota_tracker : quota_tracker_arc,
647660 batch_timeout : Duration :: from_secs (
648661 config. request_timeout_secs . clamp ( 5 , 300 ) ,
649662 ) ,
@@ -774,7 +787,9 @@ impl DomainFronter {
774787 }
775788 guard. clone ( )
776789 } ;
790+ let quota = self . quota_tracker . summary ( ) ;
777791 StatsSnapshot {
792+ total_relay_calls : quota. total_relay_calls ,
778793 relay_calls : self . relay_calls . load ( Ordering :: Relaxed ) ,
779794 relay_failures : self . relay_failures . load ( Ordering :: Relaxed ) ,
780795 coalesced : self . coalesced . load ( Ordering :: Relaxed ) ,
@@ -791,9 +806,15 @@ impl DomainFronter {
791806 h2_calls : self . h2_calls . load ( Ordering :: Relaxed ) ,
792807 h2_fallbacks : self . h2_fallbacks . load ( Ordering :: Relaxed ) ,
793808 h2_disabled : self . h2_disabled . load ( Ordering :: Relaxed ) ,
809+ quota,
794810 }
795811 }
796812
813+ /// Access the quota tracker for periodic saves and startup logging.
814+ pub fn quota_tracker ( & self ) -> & Arc < QuotaTracker > {
815+ & self . quota_tracker
816+ }
817+
797818 pub fn num_scripts ( & self ) -> usize {
798819 self . script_ids . len ( )
799820 }
@@ -819,11 +840,25 @@ impl DomainFronter {
819840 for _ in 0 ..n {
820841 let idx = self . script_idx . fetch_add ( 1 , Ordering :: Relaxed ) ;
821842 let sid = & self . script_ids [ idx % n] ;
822- if !bl. contains_key ( sid) {
843+ if !bl. contains_key ( sid) && ! self . quota_tracker . is_hard_stopped ( sid ) {
823844 return sid. clone ( ) ;
824845 }
825846 }
826- // All blacklisted: pick whichever comes off cooldown soonest.
847+ // Fallback: prefer a blacklisted-but-not-quota-exhausted account
848+ // over a fully quota-exhausted one (blacklist is transient, quota
849+ // exhaustion is per-window).
850+ let not_exhausted: Vec < _ > = bl
851+ . iter ( )
852+ . filter ( |( sid, _) | !self . quota_tracker . is_hard_stopped ( sid) )
853+ . collect ( ) ;
854+ if let Some ( ( sid, _) ) = not_exhausted. iter ( ) . min_by_key ( |( _, t) | * * t) {
855+ let sid = sid. to_string ( ) ;
856+ bl. remove ( & sid) ;
857+ return sid;
858+ }
859+ // All accounts are either quota-exhausted or blacklisted. The global
860+ // hard-stop check in do_relay_with_retry will handle the quota case.
861+ // Fall back to soonest-off-blacklist cooldown as a last resort.
827862 if let Some ( ( sid, _) ) = bl. iter ( ) . min_by_key ( |( _, t) | * * t) {
828863 let sid = sid. clone ( ) ;
829864 bl. remove ( & sid) ;
@@ -852,7 +887,10 @@ impl DomainFronter {
852887 }
853888 let idx = self . script_idx . fetch_add ( 1 , Ordering :: Relaxed ) ;
854889 let sid = & self . script_ids [ idx % n] ;
855- if !bl. contains_key ( sid) && !picked. iter ( ) . any ( |p| p == sid) {
890+ if !bl. contains_key ( sid)
891+ && !self . quota_tracker . is_hard_stopped ( sid)
892+ && !picked. iter ( ) . any ( |p| p == sid)
893+ {
856894 picked. push ( sid. clone ( ) ) ;
857895 }
858896 }
@@ -1771,6 +1809,23 @@ impl DomainFronter {
17711809 headers : & [ ( String , String ) ] ,
17721810 body : & [ u8 ] ,
17731811 ) -> Vec < u8 > {
1812+ self . quota_tracker . record_relay ( ) ;
1813+
1814+ // Block ALL relay paths (exit node + Apps Script) when every account
1815+ // bucket is quota-exhausted. Checked here so the exit node short-circuit
1816+ // below can't bypass the global hard stop.
1817+ if self . quota_tracker . is_globally_hard_stopped ( ) {
1818+ self . relay_failures . fetch_add ( 1 , Ordering :: Relaxed ) ;
1819+ tracing:: error!(
1820+ "[quota] global hard stop active — all Apps Script account buckets exhausted"
1821+ ) ;
1822+ return error_response (
1823+ 503 ,
1824+ "All Apps Script accounts quota exhausted; hard stop active. \
1825+ Quota resets on a rolling 24-hour window per account.",
1826+ ) ;
1827+ }
1828+
17741829 // Optional URL rewrite for X/Twitter GraphQL (issue #16). Applied
17751830 // here, at the top of relay(), so it affects BOTH the cache key
17761831 // (so matching requests collapse into one entry) AND the URL that
@@ -1805,6 +1860,10 @@ impl DomainFronter {
18051860 bytes. len ( ) as u64 ,
18061861 t0. elapsed ( ) . as_nanos ( ) as u64 ,
18071862 ) ;
1863+ self . bytes_relayed . fetch_add (
1864+ ( body. len ( ) + bytes. len ( ) ) as u64 ,
1865+ Ordering :: Relaxed ,
1866+ ) ;
18081867 return bytes;
18091868 }
18101869 Err ( e) if !e. is_retryable ( ) => {
@@ -2542,6 +2601,21 @@ impl DomainFronter {
25422601 headers : & [ ( String , String ) ] ,
25432602 body : & [ u8 ] ,
25442603 ) -> Result < Vec < u8 > , FronterError > {
2604+ // Refuse immediately if every configured account bucket is exhausted.
2605+ // Conservative: only triggers when all buckets are hard-stopped OR the
2606+ // aggregate remaining quota has crossed the collective safety threshold
2607+ // with confirmed quota error evidence (not random network failures).
2608+ if self . quota_tracker . is_globally_hard_stopped ( ) {
2609+ tracing:: error!(
2610+ "[quota] global hard stop active — all Apps Script account buckets exhausted"
2611+ ) ;
2612+ return Err ( FronterError :: Relay (
2613+ "All Apps Script accounts quota exhausted; hard stop active. \
2614+ Quota resets on a rolling 24-hour window per account."
2615+ . into ( ) ,
2616+ ) ) ;
2617+ }
2618+
25452619 // Fan-out path: fire N instances in parallel, return first Ok, cancel
25462620 // the rest. Clamps to number of available script IDs so the single-ID
25472621 // case is a no-op even if parallel_relay>1 was configured.
@@ -2638,6 +2712,14 @@ impl DomainFronter {
26382712 self . do_relay_once_with ( script_id, method, url, headers, body) . await
26392713 }
26402714
2715+ /// Quota-recording wrapper around `do_relay_once_inner`. Counts every
2716+ /// Apps Script fetch attempt (including retries) against the per-account
2717+ /// bucket, records byte metrics on success, and marks an account as
2718+ /// hard-stopped when the response carries a confirmed quota error message.
2719+ ///
2720+ /// Local transport failures (Io, Tls, Timeout) are recorded as failed
2721+ /// attempts but do NOT trigger exhaustion — only quota-like Relay errors
2722+ /// qualify, keeping transient network issues from false-stopping accounts.
26412723 async fn do_relay_once_with (
26422724 & self ,
26432725 script_id : String ,
@@ -2646,12 +2728,68 @@ impl DomainFronter {
26462728 headers : & [ ( String , String ) ] ,
26472729 body : & [ u8 ] ,
26482730 ) -> Result < Vec < u8 > , FronterError > {
2649- // Build once, wrap in Bytes (zero-copy move). h2 takes a clone
2650- // (Arc bump, not memcpy); h1 fallback uses the same Bytes via
2651- // Deref<&[u8]>. Saves a full payload allocation+copy per call
2652- // — meaningful on range-parallel fan-out where N copies fire
2653- // in parallel for one user-facing GET.
2731+ // Defense-in-depth: if next_script_id's last-resort fallback handed us
2732+ // a hard-stopped account (all exhausted, none in the blacklist), refuse
2733+ // here before building the payload or touching the network.
2734+ if self . quota_tracker . is_hard_stopped ( & script_id) {
2735+ return Err ( FronterError :: Relay ( format ! (
2736+ "account {} is quota-hard-stopped; skipping dispatch" ,
2737+ mask_script_id( & script_id) ,
2738+ ) ) ) ;
2739+ }
2740+
26542741 let payload: Bytes = Bytes :: from ( self . build_payload_json ( method, url, headers, body) ?) ;
2742+ let bytes_up = payload. len ( ) as u64 ;
2743+
2744+ // Count ALL attempts, including retries. Each call here maps to one
2745+ // real UrlFetchApp.fetch() on Google's side — that's the unit Google
2746+ // bills against the daily quota.
2747+ self . quota_tracker . record_attempt ( & script_id, bytes_up) ;
2748+
2749+ let result = self
2750+ . do_relay_once_inner ( script_id. clone ( ) , method, url, payload)
2751+ . await ;
2752+
2753+ match & result {
2754+ Ok ( bytes) => {
2755+ self . quota_tracker
2756+ . record_success ( & script_id, bytes. len ( ) as u64 ) ;
2757+ }
2758+ Err ( e) => {
2759+ let is_quota = is_quota_like_fronter_error ( e) ;
2760+ self . quota_tracker . record_failure ( & script_id, is_quota) ;
2761+ if is_quota {
2762+ self . quota_tracker
2763+ . mark_exhausted ( & script_id, & e. to_string ( ) ) ;
2764+ tracing:: warn!(
2765+ "[quota] account {} exhausted: {}" ,
2766+ mask_script_id( & script_id) ,
2767+ e
2768+ ) ;
2769+ }
2770+ }
2771+ }
2772+
2773+ tracing:: debug!(
2774+ "[quota] {} dispatch result: {}" ,
2775+ mask_script_id( & script_id) ,
2776+ match & result {
2777+ Ok ( _) => "Ok" . to_string( ) ,
2778+ Err ( e) => format!( "Err({})" , e) ,
2779+ } ,
2780+ ) ;
2781+
2782+ result
2783+ }
2784+
2785+ async fn do_relay_once_inner (
2786+ & self ,
2787+ script_id : String ,
2788+ method : & str ,
2789+ url : & str ,
2790+ payload : Bytes ,
2791+ ) -> Result < Vec < u8 > , FronterError > {
2792+ // payload already built by the caller; path derived from script_id.
26552793 let path = format ! ( "/macros/s/{}/exec" , script_id) ;
26562794
26572795 // h2 fast path: one shared TCP/TLS connection multiplexes all
@@ -5053,6 +5191,9 @@ fn decode_js_string_escapes(s: &str) -> Option<String> {
50535191
50545192#[ derive( Debug , Clone ) ]
50555193pub struct StatsSnapshot {
5194+ /// Total relay() calls today (exit node + Apps Script). Sourced from the
5195+ /// persisted quota tracker so this survives proxy restarts.
5196+ pub total_relay_calls : u64 ,
50565197 pub relay_calls : u64 ,
50575198 pub relay_failures : u64 ,
50585199 pub coalesced : u64 ,
@@ -5092,6 +5233,9 @@ pub struct StatsSnapshot {
50925233 /// switch set, or peer refused h2 during ALPN). All traffic on the
50935234 /// h1 path.
50945235 pub h2_disabled : bool ,
5236+ /// Quota state snapshot. Only meaningful in AppsScript/Full modes where
5237+ /// a DomainFronter is active; defaults to zero values in Direct mode.
5238+ pub quota : QuotaSummary ,
50955239}
50965240
50975241impl StatsSnapshot {
@@ -5124,8 +5268,22 @@ impl StatsSnapshot {
51245268 )
51255269 }
51265270 } ;
5271+ let q = & self . quota ;
5272+ let quota_seg = if q. account_count > 0 && ( q. exhausted_count > 0 || q. global_hard_stop ) {
5273+ format ! (
5274+ " quota={}/{} remaining={} exhausted={}/{}{}" ,
5275+ q. requests_used_total,
5276+ q. daily_capacity_total,
5277+ q. requests_remaining_total,
5278+ q. exhausted_count,
5279+ q. account_count,
5280+ if q. global_hard_stop { " HARD-STOP" } else { "" } ,
5281+ )
5282+ } else {
5283+ String :: new ( )
5284+ } ;
51275285 format ! (
5128- "stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{}" ,
5286+ "stats: relay={} ({}KB) failures={} coalesced={} cache={}/{} ({:.0}% hit, {}KB) scripts={}/{} active{}{} " ,
51295287 self . relay_calls,
51305288 self . bytes_relayed / 1024 ,
51315289 self . relay_failures,
@@ -5137,6 +5295,7 @@ impl StatsSnapshot {
51375295 self . total_scripts - self . blacklisted_scripts,
51385296 self . total_scripts,
51395297 h2_seg,
5298+ quota_seg,
51405299 )
51415300 }
51425301
@@ -5148,8 +5307,9 @@ impl StatsSnapshot {
51485307 fn esc ( s : & str ) -> String {
51495308 s. replace ( '\\' , "\\ \\ " ) . replace ( '"' , "\\ \" " )
51505309 }
5310+ let q = & self . quota ;
51515311 format ! (
5152- r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{}}}"# ,
5312+ r#"{{"relay_calls":{},"relay_failures":{},"coalesced":{},"bytes_relayed":{},"cache_hits":{},"cache_misses":{},"cache_bytes":{},"blacklisted_scripts":{},"total_scripts":{},"today_calls":{},"today_bytes":{},"today_key":"{}","today_reset_secs":{},"h2_calls":{},"h2_fallbacks":{},"h2_disabled":{},"quota_account_count":{},"quota_capacity":{},"quota_used":{},"quota_remaining":{},"quota_exhausted":{},"quota_hard_stop":{} }}"# ,
51535313 self . relay_calls,
51545314 self . relay_failures,
51555315 self . coalesced,
@@ -5166,6 +5326,12 @@ impl StatsSnapshot {
51665326 self . h2_calls,
51675327 self . h2_fallbacks,
51685328 self . h2_disabled,
5329+ q. account_count,
5330+ q. daily_capacity_total,
5331+ q. requests_used_total,
5332+ q. requests_remaining_total,
5333+ q. exhausted_count,
5334+ q. global_hard_stop,
51695335 )
51705336 }
51715337}
@@ -5177,6 +5343,18 @@ fn should_blacklist(status: u16, body: &str) -> bool {
51775343 looks_like_quota_error ( body)
51785344}
51795345
5346+ /// True only when the error is a Relay-level message that looks like a quota
5347+ /// signal from Apps Script. Io/Tls/Timeout errors are local transport issues
5348+ /// and must NOT trigger account exhaustion — that would false-stop accounts on
5349+ /// any network glitch.
5350+ fn is_quota_like_fronter_error ( e : & FronterError ) -> bool {
5351+ match e {
5352+ FronterError :: Relay ( msg) => looks_like_quota_error ( msg) ,
5353+ FronterError :: NonRetryable ( inner) => is_quota_like_fronter_error ( inner) ,
5354+ _ => false ,
5355+ }
5356+ }
5357+
51805358fn looks_like_quota_error ( msg : & str ) -> bool {
51815359 let lower = msg. to_ascii_lowercase ( ) ;
51825360 lower. contains ( "quota" )
0 commit comments