use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::config::Config;
#[cfg(test)]
static CLOCK_OFFSET_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(not(test))]
fn now() -> Instant {
Instant::now()
}
#[cfg(test)]
fn now() -> Instant {
use std::sync::atomic::Ordering;
let off = CLOCK_OFFSET_MS.load(Ordering::Relaxed);
Instant::now() + Duration::from_millis(off)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Surface {
Send,
Final,
Chrome,
}
impl Surface {
pub(crate) fn is_content(self) -> bool {
matches!(self, Surface::Send | Surface::Final)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Decision {
Admit,
Hold(Duration),
Drop,
FailOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Limits {
pub enabled: bool,
pub ceiling_per_min: u32,
pub burst: u32,
pub chrome_spacing: Duration,
pub content_max_hold: Duration,
pub pause_fallback: Duration,
pub pause_ceiling: Duration,
}
impl Limits {
fn from_config() -> Self {
let g = &Config::current().channels.slack.governor;
Self {
enabled: g.enabled,
ceiling_per_min: g.writes_per_minute,
burst: g.burst.max(1),
chrome_spacing: Duration::from_millis(g.chrome_min_spacing_ms),
content_max_hold: Duration::from_secs(25 * 60),
pause_fallback: Duration::from_secs(g.pause_secs.max(1)),
pause_ceiling: Duration::from_secs(g.pause_secs.max(1) * 4),
}
}
}
#[derive(Debug, Default)]
pub(crate) struct ChannelBudget {
pub(crate) tokens: f64,
pub(crate) last_refill: Option<Instant>,
pub(crate) last_chrome: Option<Instant>,
pub(crate) pause_until: Option<Instant>,
}
impl ChannelBudget {
fn refresh(&mut self, l: &Limits, now: Instant) {
if l.ceiling_per_min == 0 {
self.tokens = f64::INFINITY;
self.last_refill = Some(now);
} else {
let capacity = l.burst.max(1) as f64;
let rate = l.ceiling_per_min as f64 / 60.0;
self.tokens = match self.last_refill {
Some(last) => {
let dt = now.saturating_duration_since(last).as_secs_f64();
(self.tokens + dt * rate).min(capacity)
}
None => capacity,
};
self.last_refill = Some(now);
}
if self.pause_until.is_some_and(|until| until <= now) {
self.pause_until = None;
}
}
fn is_paused(&self, now: Instant) -> bool {
self.pause_until.is_some_and(|until| until > now)
}
}
pub(crate) fn admit(
b: &mut ChannelBudget,
l: &Limits,
surface: Surface,
held: Duration,
now: Instant,
) -> Decision {
if !l.enabled {
return Decision::Admit;
}
b.refresh(l, now);
if b.is_paused(now) {
let until = b.pause_until.unwrap_or(now);
let wait = until.saturating_duration_since(now);
return if surface.is_content() {
if held + wait > l.content_max_hold {
Decision::FailOpen
} else {
Decision::Hold(wait)
}
} else {
Decision::Drop
};
}
if surface == Surface::Chrome
&& !l.chrome_spacing.is_zero()
&& let Some(last) = b.last_chrome
&& now.saturating_duration_since(last) < l.chrome_spacing
{
return Decision::Drop;
}
if l.ceiling_per_min > 0 && b.tokens < 1.0 {
let need = Duration::from_secs_f64((1.0 - b.tokens) * 60.0 / l.ceiling_per_min as f64);
if !surface.is_content() {
return Decision::Drop;
}
if held + need > l.content_max_hold {
tracing::warn!(
"Slack: governor write ceiling held a content write past its ceiling, \
failing open (#2012)"
);
return Decision::FailOpen;
}
return Decision::Hold(need);
}
if l.ceiling_per_min > 0 {
b.tokens -= 1.0;
}
if surface == Surface::Chrome {
b.last_chrome = Some(now);
}
Decision::Admit
}
fn arm_park(b: &mut ChannelBudget, until: Instant, now: Instant) -> Duration {
match b.pause_until {
Some(existing) if existing >= until => existing.saturating_duration_since(now),
_ => {
b.pause_until = Some(until);
until.saturating_duration_since(now)
}
}
}
pub(crate) fn note_429(
b: &mut ChannelBudget,
l: &Limits,
retry_after: Option<Duration>,
now: Instant,
) -> Duration {
let requested = retry_after.unwrap_or(l.pause_fallback);
let clamped = requested.min(l.pause_ceiling);
arm_park(b, now + clamped, now)
}
use std::sync::OnceLock;
use futures::FutureExt;
use futures::future::BoxFuture;
use slack_morphism::errors::SlackClientError;
use slack_morphism::prelude::{
SlackApiChatPostMessageRequest, SlackApiChatPostMessageResponse, SlackApiChatUpdateRequest,
SlackApiChatUpdateResponse, SlackClientHyperHttpsConnector, SlackClientSession, ValueStruct,
};
#[derive(Debug, Default)]
pub(crate) struct Governor {
budgets: Mutex<HashMap<String, ChannelBudget>>,
}
const MAX_HOLD_ROUNDS: usize = 512;
impl Governor {
pub(crate) fn global() -> &'static Governor {
static GLOBAL: OnceLock<Governor> = OnceLock::new();
GLOBAL.get_or_init(Governor::default)
}
pub(crate) async fn gate(&self, conversation: &str, surface: Surface) -> bool {
let limits = Limits::from_config();
let start = now();
for round in 0..MAX_HOLD_ROUNDS {
let at = start.max(now());
let held = at.saturating_duration_since(start);
let decision = {
let mut guard = match self.budgets.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let budget = guard.entry(conversation.to_owned()).or_default();
admit(budget, &limits, surface, held, at)
};
match decision {
Decision::Admit | Decision::FailOpen => return true,
Decision::Drop => {
tracing::debug!(
"Slack: governor dropped a chrome write on {conversation} (#2012)"
);
return false;
}
Decision::Hold(wait) => {
tracing::debug!(
"Slack: governor held a {surface:?} write on {conversation} for {wait:?} \
(round {round}, #2012)"
);
tokio::time::sleep(wait).await;
}
}
}
tracing::warn!(
"Slack: governor held a write on {conversation} for {MAX_HOLD_ROUNDS} rounds, \
sending anyway (#2012)"
);
true
}
pub(crate) fn note_rate_limited(&self, conversation: &str, retry_after: Option<Duration>) {
let limits = Limits::from_config();
let pause = {
let mut guard = match self.budgets.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
note_429(
guard.entry(conversation.to_owned()).or_default(),
&limits,
retry_after,
now(),
)
};
tracing::warn!(
conversation,
pause_ms = pause.as_millis() as u64,
named_ms = retry_after.map(|d| d.as_millis() as u64),
"Slack: 429 on {conversation}, parking outbound writes for {} ms{}",
pause.as_millis(),
match retry_after {
Some(d) => format!(" (API named {d:?}, clamped to the ceiling)"),
None => " (response named no Retry-After, fallback window)".to_string(),
}
);
}
pub(crate) fn is_rate_limited(err: &SlackClientError) -> bool {
matches!(err, SlackClientError::RateLimitError(_))
}
pub(crate) fn retry_after(err: &SlackClientError) -> Option<Duration> {
match err {
SlackClientError::RateLimitError(e) => e.retry_after.filter(|d| !d.is_zero()),
_ => None,
}
}
}
pub(crate) fn note_if_429<T>(
governor: &Governor,
conversation: &str,
result: &Result<T, SlackClientError>,
) {
if let Err(err) = result
&& Governor::is_rate_limited(err)
{
governor.note_rate_limited(conversation, Governor::retry_after(err));
}
}
pub(crate) trait GatedWrites {
fn post<'a>(
&'a self,
req: &'a SlackApiChatPostMessageRequest,
) -> BoxFuture<'a, Result<SlackApiChatPostMessageResponse, SlackClientError>>;
fn update<'a>(
&'a self,
req: &'a SlackApiChatUpdateRequest,
) -> BoxFuture<'a, Result<SlackApiChatUpdateResponse, SlackClientError>>;
fn update_chrome<'a>(
&'a self,
req: &'a SlackApiChatUpdateRequest,
) -> BoxFuture<'a, Option<Result<SlackApiChatUpdateResponse, SlackClientError>>>;
}
impl GatedWrites for SlackClientSession<'_, SlackClientHyperHttpsConnector> {
fn post<'a>(
&'a self,
req: &'a SlackApiChatPostMessageRequest,
) -> BoxFuture<'a, Result<SlackApiChatPostMessageResponse, SlackClientError>> {
let conversation = ValueStruct::value(&req.channel).to_owned();
async move {
let governor = Governor::global();
governor.gate(&conversation, Surface::Send).await;
let result = self.chat_post_message(req).await;
note_if_429(governor, &conversation, &result);
result
}
.boxed()
}
fn update<'a>(
&'a self,
req: &'a SlackApiChatUpdateRequest,
) -> BoxFuture<'a, Result<SlackApiChatUpdateResponse, SlackClientError>> {
let conversation = ValueStruct::value(&req.channel).to_owned();
async move {
let governor = Governor::global();
governor.gate(&conversation, Surface::Final).await;
let result = self.chat_update(req).await;
note_if_429(governor, &conversation, &result);
result
}
.boxed()
}
fn update_chrome<'a>(
&'a self,
req: &'a SlackApiChatUpdateRequest,
) -> BoxFuture<'a, Option<Result<SlackApiChatUpdateResponse, SlackClientError>>> {
let conversation = ValueStruct::value(&req.channel).to_owned();
async move {
let governor = Governor::global();
if !governor.gate(&conversation, Surface::Chrome).await {
return None;
}
let result = self.chat_update(req).await;
note_if_429(governor, &conversation, &result);
Some(result)
}
.boxed()
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::sync::atomic::Ordering;
pub(crate) async fn registry_guard() -> tokio::sync::MutexGuard<'static, ()> {
static GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
GUARD.lock().await
}
pub(crate) fn reset() {
CLOCK_OFFSET_MS.store(0, Ordering::Relaxed);
}
pub(crate) fn advance(ms: u64) {
CLOCK_OFFSET_MS.fetch_add(ms, Ordering::Relaxed);
}
}