#340: POST /api/signals/icon spends the server's TYPESAFE_KEY for anyone signed in, whatever signals_on_server says, and is not tied to a Signal, so a loop on it ran up the bill and, through the shared pace and a 429's back-off, held back everyone's judging. Each person now has 20 picks an hour; past that it is a 429 the page shows as a toast. #343: on the server's key, calls() put everyone's Signals on an item in one call (#275), so one person's free-text description was part of the question that decided another's verdicts. Each person's Signals on an item are now a call of their own. The saving #275 made is lost only where two people have Signals on the same item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333).
|
||||
- An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338).
|
||||
- Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324).
|
||||
- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340).
|
||||
- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343).
|
||||
|
||||
## [1.4.0] - 2026-10-19
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
hits = tracing::field::Empty
|
||||
);
|
||||
async {
|
||||
let groups = calls(&items, server.is_some());
|
||||
let groups = calls(&items);
|
||||
let paid = if server.is_some() { "server" } else { "own" };
|
||||
tracing::Span::current().record("calls", groups.len());
|
||||
let refused = std::sync::Mutex::new(HashSet::new());
|
||||
@@ -184,23 +184,11 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The calls a batch makes, each one item and the people asking about it. On the server's key
|
||||
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys
|
||||
/// each pays for their own. `unjudged` puts one item's rows together.
|
||||
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> {
|
||||
let mut out: Vec<Vec<&Unjudged>> = Vec::new();
|
||||
for item in items {
|
||||
match out.last_mut() {
|
||||
Some(g)
|
||||
if share
|
||||
&& (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) =>
|
||||
{
|
||||
g.push(item)
|
||||
}
|
||||
_ => out.push(vec![item]),
|
||||
}
|
||||
}
|
||||
out
|
||||
/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
|
||||
/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
|
||||
/// then one person's description was in the question that decided another's verdicts (#343).
|
||||
fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
|
||||
items.iter().map(|item| vec![item]).collect()
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
@@ -590,7 +578,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_the_servers_key_one_item_is_one_call_for_everyone() {
|
||||
fn one_persons_signals_never_share_a_call_with_anothers() {
|
||||
let row = |user_id, guid: &str| Unjudged {
|
||||
user_id,
|
||||
user_name: format!("u{user_id}"),
|
||||
@@ -603,14 +591,11 @@ mod tests {
|
||||
signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }],
|
||||
};
|
||||
let items = [row(1, "x"), row(2, "x"), row(1, "y")];
|
||||
let shape = |share| {
|
||||
calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both");
|
||||
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key");
|
||||
let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect();
|
||||
let r = request(&items[0], &both);
|
||||
assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object());
|
||||
let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
|
||||
assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)");
|
||||
let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect();
|
||||
let r = request(&items[0], &mine);
|
||||
assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -498,6 +498,30 @@ struct IconAsk {
|
||||
candidates: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// How many icons one person may have Jev pick in an hour. Each is a call on the server's key, and
|
||||
/// the route is not tied to a Signal, so a loop on it ran up the server's bill and held back
|
||||
/// everyone's Signals behind the shared pace (#340). A Signal is named a few times at most.
|
||||
const ICON_PICKS_AN_HOUR: u32 = 20;
|
||||
static ICON_PICKS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<i64, (std::time::Instant, u32)>>> =
|
||||
std::sync::LazyLock::new(Default::default);
|
||||
|
||||
/// Counts a pick against the person's hour, which starts at their first pick in it.
|
||||
fn icon_pick_allowed(
|
||||
picks: &mut std::collections::HashMap<i64, (std::time::Instant, u32)>,
|
||||
user: i64,
|
||||
now: std::time::Instant,
|
||||
) -> bool {
|
||||
let (start, used) = picks.entry(user).or_insert((now, 0));
|
||||
if now.duration_since(*start) >= std::time::Duration::from_secs(3600) {
|
||||
(*start, *used) = (now, 0);
|
||||
}
|
||||
if *used >= ICON_PICKS_AN_HOUR {
|
||||
return false;
|
||||
}
|
||||
*used += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
|
||||
/// or 404 when the server has no key.
|
||||
async fn pick_signal_icon(
|
||||
@@ -508,6 +532,9 @@ async fn pick_signal_icon(
|
||||
let Some(key) = crate::signals::typesafe_key() else {
|
||||
return Err(ApiError::not_found("picking an icon is not set up on this server"));
|
||||
};
|
||||
if !icon_pick_allowed(&mut ICON_PICKS.lock().unwrap(), user.id, std::time::Instant::now()) {
|
||||
return Err(ApiError::too_many("enough icons picked for one hour; choose one by hand"));
|
||||
}
|
||||
let name = signal_name(&body.name)?;
|
||||
let words = clean_words(body.keywords)?;
|
||||
let description = signal_description(body.description)?;
|
||||
@@ -1250,6 +1277,10 @@ impl ApiError {
|
||||
fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY }
|
||||
}
|
||||
|
||||
fn too_many(msg: impl Into<String>) -> Self {
|
||||
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::TOO_MANY_REQUESTS }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
@@ -1265,6 +1296,18 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn icon_picks_are_counted_by_the_hour_and_the_person() {
|
||||
let mut picks = std::collections::HashMap::new();
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..ICON_PICKS_AN_HOUR {
|
||||
assert!(icon_pick_allowed(&mut picks, 1, t));
|
||||
}
|
||||
assert!(!icon_pick_allowed(&mut picks, 1, t), "one too many (#340)");
|
||||
assert!(icon_pick_allowed(&mut picks, 2, t), "someone else has their own hour");
|
||||
assert!(icon_pick_allowed(&mut picks, 1, t + std::time::Duration::from_secs(3600)), "the next hour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_sent_from_another_site_is_known() {
|
||||
let h = |pairs: &[(&str, &str)]| {
|
||||
|
||||
Reference in New Issue
Block a user