dictserv: retry a transient lookup once, and end the read on a no-match/error status instead of blocking for a 250 that never comes
All checks were successful
CI / check (push) Successful in 4m0s

This commit is contained in:
Jean Chevronnet 2026-07-18 23:20:34 +00:00
parent 623750e638
commit 03f9c2d3bd
No known key found for this signature in database

View file

@ -7,22 +7,28 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream; use tokio::net::TcpStream;
const TIMEOUT: Duration = Duration::from_secs(6); const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(6);
const ATTEMPTS: usize = 2; // one retry — a database that's briefly slow under load often answers next try
const MAX_LEN: usize = 400; // keep a spoken reply to about one IRC line const MAX_LEN: usize = 400; // keep a spoken reply to about one IRC line
// Look `word` up in `database` on `server`. Never errors out to the caller: on a // Look `word` up in `database` on `server`. Never errors out to the caller: on a
// timeout, connection failure, or no match it returns a short human message, so // timeout, connection failure, or no match it returns a short human message, so
// the bot always has something safe to say. // the bot always has something safe to say. A transient failure (timeout or
// connection error) is retried once, since dict.org databases can be momentarily
// slow and then recover; a clean "no match" is definitive and returns at once.
pub async fn lookup(server: &str, database: &str, word: &str) -> String { pub async fn lookup(server: &str, database: &str, word: &str) -> String {
let word = sanitize(word); let word = sanitize(word);
if word.is_empty() { if word.is_empty() {
return "nothing to look up.".to_string(); return "nothing to look up.".to_string();
} }
match tokio::time::timeout(TIMEOUT, exchange(server, database, &word)).await { for _ in 0..ATTEMPTS {
Ok(Ok(Some(def))) => def, match tokio::time::timeout(ATTEMPT_TIMEOUT, exchange(server, database, &word)).await {
Ok(Ok(None)) => format!("no definition found for \x02{word}\x02."), Ok(Ok(Some(def))) => return def,
Ok(Err(_)) | Err(_) => "the dictionary is unavailable right now.".to_string(), Ok(Ok(None)) => return format!("no definition found for \x02{word}\x02."),
Ok(Err(_)) | Err(_) => continue, // transient — try again, then give up
}
} }
"the dictionary is unavailable right now.".to_string()
} }
// One request/response round trip. Returns the first definition's text, or None // One request/response round trip. Returns the first definition's text, or None
@ -36,15 +42,24 @@ async fn exchange(server: &str, database: &str, word: &str) -> std::io::Result<O
lines.next_line().await?; lines.next_line().await?;
wr.write_all(format!("DEFINE {database} \"{word}\"\r\n").as_bytes()).await?; wr.write_all(format!("DEFINE {database} \"{word}\"\r\n").as_bytes()).await?;
let mut raw = String::new(); let mut raw = String::new();
let mut in_def = false;
while let Some(line) = lines.next_line().await? { while let Some(line) = lines.next_line().await? {
// 250 ends the whole response; stop reading once we've seen it. // Stop as soon as we have an answer, rather than blocking for a 250 that a
if line.starts_with("250 ") { // no-match never sends: the first definition's terminating ".", or — before
break; // any definition — a terminal status ("552 no match", "550" bad db, a bare
} // 250, or a 4xx/5xx error). "150 n" is just the count, so it's not terminal.
let stop = if in_def {
line == "."
} else if line.starts_with("151") {
in_def = true;
false
} else {
!line.starts_with("150") && line.chars().next().is_some_and(|c| matches!(c, '2' | '4' | '5'))
};
raw.push_str(&line); raw.push_str(&line);
raw.push('\n'); raw.push('\n');
if raw.len() > MAX_LEN * 8 { if stop || raw.len() > MAX_LEN * 8 {
break; // guard against a runaway server break;
} }
} }
let _ = wr.write_all(b"QUIT\r\n").await; let _ = wr.write_all(b"QUIT\r\n").await;