Skip to content

Commit 96237d1

Browse files
feat(quota): track Apps Script account usage
Validated locally: cargo test --lib (249 passed) and cargo check --features ui --bin mhrv-rs-ui.
1 parent 0eaddbc commit 96237d1

6 files changed

Lines changed: 1146 additions & 124 deletions

File tree

src/bin/ui.rs

Lines changed: 248 additions & 112 deletions
Large diffs are not rendered by default.

src/config.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,21 @@ pub struct Config {
411411
/// Setup walkthrough at `assets/exit_node/README.md`. Default off.
412412
#[serde(default)]
413413
pub exit_node: ExitNodeConfig,
414+
415+
/// Daily request quota per account bucket. Each configured script_id is
416+
/// treated as one separate account. Default 20_000 matches the free-tier
417+
/// Apps Script UrlFetchApp limit. Set to 100_000 for Workspace accounts.
418+
#[serde(default = "default_quota_daily_limit")]
419+
pub quota_daily_limit: u64,
420+
421+
/// Per-account safety buffer. An account is considered effectively
422+
/// exhausted when its remaining requests for the current 24-hour window
423+
/// drop below this value. The reserve intentionally keeps calls away from
424+
/// Google's hard quota edge to avoid triggering anti-abuse heuristics.
425+
/// Aggregate hard-stop reserve = account_count × quota_safety_buffer.
426+
/// Default 500.
427+
#[serde(default = "default_quota_safety_buffer")]
428+
pub quota_safety_buffer: u64,
414429
}
415430

416431
/// Configuration for the optional second-hop exit node.
@@ -539,6 +554,8 @@ fn default_block_doh() -> bool { true }
539554
fn default_auto_blacklist_strikes() -> u32 { 3 }
540555
fn default_auto_blacklist_window_secs() -> u64 { 30 }
541556
fn default_auto_blacklist_cooldown_secs() -> u64 { 120 }
557+
fn default_quota_daily_limit() -> u64 { 20_000 }
558+
fn default_quota_safety_buffer() -> u64 { 500 }
542559

543560
/// Default for `request_timeout_secs`: 30s, matching the historical
544561
/// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff.
@@ -940,6 +957,8 @@ impl From<TomlConfig> for Config {
940957
request_timeout_secs: t.relay.request_timeout_secs,
941958
stream_timeout_secs: t.relay.stream_timeout_secs,
942959
exit_node: t.exit_node,
960+
quota_daily_limit: default_quota_daily_limit(),
961+
quota_safety_buffer: default_quota_safety_buffer(),
943962
}
944963
}
945964
}

src/domain_fronter.rs

Lines changed: 188 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
4242

4343
use crate::cache::{cache_key, is_cacheable_method, parse_ttl, ResponseCache};
4444
use crate::config::Config;
45+
use crate::quota_tracker::{QuotaSummary, QuotaTracker};
4546

4647
#[derive(Debug, thiserror::Error)]
4748
pub 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)]
50555193
pub 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

50975241
impl 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+
51805358
fn looks_like_quota_error(msg: &str) -> bool {
51815359
let lower = msg.to_ascii_lowercase();
51825360
lower.contains("quota")

0 commit comments

Comments
 (0)