Skip to content

Commit d62b319

Browse files
committed
auto-blacklist failing scripts in round-robin rotation
When a script returns 429, 403, or a quota/rate-limit error body, drop it from the active rotation for 10 minutes. next_script_id skips blacklisted IDs; if all are blacklisted, picks the one coming off cooldown soonest. Script IDs are masked in logs (prefix...suffix) to avoid leaking the deployment ID even at info level.
1 parent 534066c commit d62b319

1 file changed

Lines changed: 99 additions & 11 deletions

File tree

src/domain_fronter.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
7982
struct 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+
730801
fn 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

Comments
 (0)