From 44b006821df32e2a1c12ba4ca93b168a40c6ed71 Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:23:46 +0000 Subject: [PATCH] Skip an account that already has two calls in flight so a crawl keeps moving. An unlimited per-account cap lets one burst pile onto the few accounts already known to work. Co-authored-by: Cursor --- README.md | 10 ++++++---- src/api/auth.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 004aa68..fd64d5b 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,12 @@ second search does not pay a new SOCKS + TLS handshake. ### Rate limits and cache -- `maxConcurrentReqs` is **per cookie session**, not global. A busy public - instance should set it to `1000000`. At that size the cap never queues a - call, which matters once the instance is popular enough that every account - would otherwise sit at a small limit. +- `maxConcurrentReqs` is **per cookie session**, not global. At `2`, an + account that already has two calls in flight is skipped and the next + account is used immediately. A crawl stays at two in flight per account + across the whole pool. Raising it into the millions lets one burst pile + onto the few accounts already known to work, which is what produces a + cliff of 429s. - `clientBudget` caps how fast one visitor IP can spend uncached X calls. Cache hits, media, and static files are free. SearchTimeline costs 2 tokens. - First-page search is cached for 60 seconds, then served stale for 4 more diff --git a/src/api/auth.rs b/src/api/auth.rs index 09c4142..ec186ac 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -356,7 +356,11 @@ impl SessionPool { } // Spend the local window under the write lock, then take a permit. - // A session that is only busy is remembered so we can wait on it. + // An account already at its concurrency cap is skipped. The call + // goes to the next account that still has a free slot, so a crawl + // keeps moving instead of queueing behind the account that filled up. + // Waiting is only for the case where every usable account is already + // at the cap. let mut wait_on = None; for index in eligible { let api = api_for(self.sessions[index].credentials.kind); @@ -1056,6 +1060,52 @@ mod tests { ); } + #[tokio::test] + async fn a_full_account_hands_the_next_call_over_immediately() { + unsafe { env::set_var("TEAPOT_SESSION_STATE_FILE", "") }; + let path = env::temp_dir().join(format!( + "teapot-session-pool-spread-{}.jsonl", + process::id() + )); + fs::write( + &path, + concat!( + r#"{"id":1,"username":"one","kind":"cookie","auth_token":"a","ct0":"c"}"#, + "\n", + r#"{"id":2,"username":"two","kind":"cookie","auth_token":"b","ct0":"d"}"#, + "\n" + ), + ) + .await + .unwrap(); + let pool = SessionPool::load(path.to_str().unwrap(), 2).await.unwrap(); + + let first = pool + .acquire("TweetDetail", Some(SessionKind::Cookie)) + .await + .unwrap(); + let other = if first.id == 1 { 2 } else { 1 }; + let second = pool + .acquire_excluding("TweetDetail", Some(SessionKind::Cookie), Some(other)) + .await + .unwrap(); + assert_eq!(first.id, second.id); + + let next = timeout( + Duration::from_millis(50), + pool.acquire("TweetDetail", Some(SessionKind::Cookie)), + ) + .await + .expect("a full account should be skipped, not waited on") + .unwrap(); + assert_eq!(next.id, other); + + drop(first); + drop(second); + drop(next); + fs::remove_file(path).await.unwrap(); + } + #[tokio::test] async fn pool_filters_kinds_rotates_and_enforces_concurrency() { let path = env::temp_dir().join(format!("teapot-session-pool-{}.jsonl", process::id()));