mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
Every test went through `MockSender`, so `media_sender`'s `Bot` implementation — the URL it builds, the multipart it sends, the per-chat limiter and the bot-wide budget it charges — was never exercised, and neither was any handler reached from a real update. The two things that made that hard are gone: - `media_sender::test_support::fake_api::FakeApi` is a stand-in for `api.telegram.org`: a `tokio` TCP listener that reads one HTTP/1.1 request (JSON or multipart), records it and answers the smallest result the method needs. No new dependency, and `Bot::new(token).set_api_url(api.url())` points a real `Bot` at it. Note for future tests: teloxide keys methods by payload type, so the path is `SendMediaGroup`, not `sendMediaGroup`. - `message_handler` built its own `AppContext::from_statics` internally, so no test could reach its branches; its body is now `handle_message(ctx, bot, message)` with `message_handler` as the thin `dptree` entry. Tests: a media group through the real `Bot` (asserting the multipart fields — chat, media URL, caption — and that the send charged the chat's limiter), the forward button through the real callback path (`CopyMessages`, `DeleteMessage`, `AnswerCallbackQuery` with the prompt's ids and the toast text), and `handle_message` twice (a prompt reply becoming an `EditMessageCaption`, and a supported link in a group producing the one explanatory `SendMessage`). Also closes the redirect-hop gap left open by the download guard: the live `a_redirect_into_the_hosts_network_is_refused` follows a public redirector to `169.254.169.254` and asserts the policy refuses the hop (verified against httpbin.org here, and by mutation — disabling the hop check fails it). Docs: AGENTS.md's testing conventions and untested-modules list (the Bot implementation and the handler branches are covered now; `main.rs`'s startup/shutdown and its `dptree` tree still are not). `cargo fmt`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked` (201 passed, 16 ignored) clean.
227 lines
8.6 KiB
Rust
227 lines
8.6 KiB
Rust
//! Per-chat token-bucket rate limiting.
|
|
//!
|
|
//! Telegram throttles bots on two budgets: one per chat (roughly 20
|
|
//! messages/min for channels/groups) and a bot-wide one (~30 messages per
|
|
//! second). Both are smoothed here *before* the burst reaches the API — the
|
|
//! per-chat bucket charges one token per message, and [`acquire_global`]
|
|
//! charges the same spend against the bot-wide budget, which no per-chat
|
|
//! bucket can see (a forward fanned out over many chats spends one token in
|
|
//! each and nothing anywhere). The queue retry stays as the safety net for
|
|
//! whatever neither bucket models.
|
|
|
|
use parking_lot::Mutex;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::LazyLock;
|
|
use std::time::Duration;
|
|
|
|
/// Burst capacity: how many messages may be sent at once without waiting.
|
|
const CAPACITY: f64 = 20.0;
|
|
/// Sustained refill: ~20 messages per minute.
|
|
const REFILL_PER_SEC: f64 = 20.0 / 60.0;
|
|
|
|
/// The bot-wide budget: Telegram allows roughly 30 messages per second for a
|
|
/// bot in total, independently of the per-chat limits. Set to the documented
|
|
/// ceiling, so it only ever binds on a cross-chat burst.
|
|
const GLOBAL_CAPACITY: f64 = 30.0;
|
|
const GLOBAL_REFILL_PER_SEC: f64 = 30.0;
|
|
|
|
struct State {
|
|
/// Current token balance; may go negative (debt from an acquire larger
|
|
/// than the capacity, repaid by subsequent refills).
|
|
tokens: f64,
|
|
last_refill: tokio::time::Instant,
|
|
}
|
|
|
|
/// A token bucket: at most `CAPACITY` tokens accumulate, refilled at
|
|
/// `REFILL_PER_SEC`. [`TokenBucket::acquire`] consumes `n` tokens, waiting
|
|
/// for the deficit (a single acquire may exceed the capacity and goes into
|
|
/// debt, which the refill repays).
|
|
pub struct TokenBucket {
|
|
capacity: f64,
|
|
refill_per_sec: f64,
|
|
state: Mutex<State>,
|
|
}
|
|
|
|
impl TokenBucket {
|
|
fn new(capacity: f64, refill_per_sec: f64) -> Self {
|
|
TokenBucket {
|
|
capacity,
|
|
refill_per_sec,
|
|
state: Mutex::new(State {
|
|
tokens: capacity,
|
|
last_refill: tokio::time::Instant::now(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Applies the elapsed refill to `state`. Shared by [`Self::acquire`] and
|
|
/// the idle check so the two cannot drift apart.
|
|
fn refill(&self, state: &mut State) {
|
|
let now = tokio::time::Instant::now();
|
|
let elapsed = now
|
|
.saturating_duration_since(state.last_refill)
|
|
.as_secs_f64();
|
|
// Refill up to the capacity; a debt (negative balance) is repaid
|
|
// before any surplus accumulates.
|
|
state.tokens = (state.tokens + elapsed * self.refill_per_sec).min(self.capacity);
|
|
state.last_refill = now;
|
|
}
|
|
|
|
/// Waits until `n` tokens are available, consuming them. The wait is
|
|
/// bounded: the deficit is committed as debt and repaid over time, so a
|
|
/// large acquire returns once its share of the refill budget has passed.
|
|
pub async fn acquire(&self, n: f64) {
|
|
// The parking_lot guard is confined to this block: only the plain
|
|
// `wait` duration crosses the await (a guard across an await point
|
|
// would make the future !Send).
|
|
let wait = {
|
|
let mut state = self.state.lock();
|
|
self.refill(&mut state);
|
|
if state.tokens >= n {
|
|
state.tokens -= n;
|
|
return;
|
|
}
|
|
// Commit the whole consumption now; the caller proceeds once the
|
|
// deficit's worth of refill time has passed.
|
|
let debt = n - state.tokens;
|
|
state.tokens = -debt;
|
|
debt / self.refill_per_sec
|
|
};
|
|
tokio::time::sleep(Duration::from_secs_f64(wait)).await;
|
|
}
|
|
|
|
/// Current balance, for the tests that assert a call site charged the
|
|
/// bucket (a charge is otherwise only observable as a delay).
|
|
#[cfg(test)]
|
|
pub(crate) fn tokens(&self) -> f64 {
|
|
let mut state = self.state.lock();
|
|
self.refill(&mut state);
|
|
state.tokens
|
|
}
|
|
|
|
/// True when the bucket has refilled to capacity: no debt outstanding, so
|
|
/// the chat has not sent anything recently.
|
|
fn is_idle(&self) -> bool {
|
|
let mut state = self.state.lock();
|
|
self.refill(&mut state);
|
|
state.tokens >= self.capacity
|
|
}
|
|
}
|
|
|
|
/// One limiter per chat, created on first use. Per-chat so one chat's burst
|
|
/// never throttles another.
|
|
static LIMITERS: LazyLock<Mutex<HashMap<i64, Arc<TokenBucket>>>> =
|
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
/// Returns the shared limiter for a chat, creating it on first use.
|
|
pub fn limiter_for(chat_id: i64) -> Arc<TokenBucket> {
|
|
LIMITERS
|
|
.lock()
|
|
.entry(chat_id)
|
|
.or_insert_with(|| Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC)))
|
|
.clone()
|
|
}
|
|
|
|
/// The one bucket every chat shares: Telegram's bot-wide budget.
|
|
static GLOBAL_LIMITER: LazyLock<TokenBucket> =
|
|
LazyLock::new(|| TokenBucket::new(GLOBAL_CAPACITY, GLOBAL_REFILL_PER_SEC));
|
|
|
|
/// Waits for `n` messages' worth of the bot-wide budget. Called by the send
|
|
/// paths next to their per-chat [`limiter_for`]: at ~30/s it does not bind on
|
|
/// a single chat, but a batch fanned out over many chats has no other guard.
|
|
pub async fn acquire_global(n: f64) {
|
|
GLOBAL_LIMITER.acquire(n).await;
|
|
}
|
|
|
|
/// Drops limiters that are idle (refilled to capacity, so the chat has not
|
|
/// sent recently) and are not still held by an in-flight sender. The map
|
|
/// would otherwise keep one bucket per chat that ever sent media, forever.
|
|
/// Called from the periodic sweep; returns how many were dropped.
|
|
pub fn prune_idle() -> usize {
|
|
let mut limiters = LIMITERS.lock();
|
|
let before = limiters.len();
|
|
// Lock order map → bucket, the only order taken anywhere.
|
|
limiters.retain(|_, bucket| Arc::strong_count(bucket) > 1 || !bucket.is_idle());
|
|
before - limiters.len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn limiter_for_reuses_the_per_chat_bucket() {
|
|
let a = limiter_for(1);
|
|
let b = limiter_for(1);
|
|
let c = limiter_for(2);
|
|
assert!(Arc::ptr_eq(&a, &b), "same chat → same bucket");
|
|
assert!(!Arc::ptr_eq(&a, &c), "different chat → different bucket");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn burst_is_consumed_instantly_then_refill_waits() {
|
|
let bucket = TokenBucket::new(3.0, 1.0);
|
|
// A burst within capacity passes without waiting.
|
|
bucket.acquire(3.0).await;
|
|
// The bucket is empty now; one token needs 1s of refill.
|
|
let start = tokio::time::Instant::now();
|
|
bucket.acquire(1.0).await;
|
|
assert!(
|
|
start.elapsed() >= Duration::from_secs(1),
|
|
"elapsed {:?}",
|
|
start.elapsed()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn acquire_larger_than_capacity_waits_for_the_deficit() {
|
|
let bucket = TokenBucket::new(2.0, 1.0);
|
|
// 5 tokens with a capacity of 2: the 3-token deficit takes 3s.
|
|
let start = tokio::time::Instant::now();
|
|
bucket.acquire(5.0).await;
|
|
assert!(
|
|
start.elapsed() >= Duration::from_secs(3),
|
|
"elapsed {:?}",
|
|
start.elapsed()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn the_global_budget_is_paced_and_shared() {
|
|
// Drain the process-wide budget (no other test touches it: the send
|
|
// paths that use it are mocked), then prove the next message waits for
|
|
// the refill instead of going out instantly.
|
|
acquire_global(GLOBAL_CAPACITY).await;
|
|
let start = tokio::time::Instant::now();
|
|
acquire_global(1.0).await;
|
|
assert!(
|
|
start.elapsed() >= Duration::from_secs_f64(1.0 / GLOBAL_REFILL_PER_SEC),
|
|
"a fanned-out burst must be paced: elapsed {:?}",
|
|
start.elapsed()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn prune_idle_drops_full_unheld_buckets_only() {
|
|
// Held by this task: kept even at full capacity, a sender has it.
|
|
let held = limiter_for(9_001);
|
|
assert!(held.is_idle(), "a fresh bucket is full");
|
|
// Only the map holds this one and it is full → dropped.
|
|
limiter_for(9_002);
|
|
// Mid-debt (an acquire larger than the capacity): kept.
|
|
{
|
|
let bucket = Arc::new(TokenBucket::new(CAPACITY, REFILL_PER_SEC));
|
|
bucket.state.lock().tokens = -1.0;
|
|
LIMITERS.lock().insert(9_003, bucket);
|
|
}
|
|
|
|
assert!(prune_idle() >= 1);
|
|
|
|
let limiters = LIMITERS.lock();
|
|
assert!(limiters.contains_key(&9_001), "held bucket pruned");
|
|
assert!(!limiters.contains_key(&9_002), "idle unheld bucket kept");
|
|
assert!(limiters.contains_key(&9_003), "indebted bucket pruned");
|
|
}
|
|
}
|