Skip to content

Commit 172ef66

Browse files
nolikclaude
andauthored
feat(handlers): return Result and remove runtime panic paths (#605)
Handlers now return `Result<(), AppError>`; the dispatcher endpoint collects the outcome and logs any error once at the boundary, swallowing it so a single bad update can't tear down the dispatcher (§1). Panic sites removed: - chat_gpt_handler: `msg.text().expect(...)` (x2) -> `let Some(..) else`. - url_summary_handler: `get_content_call(..).unwrap()` and `html2text::from_read(..).unwrap()` -> `?` (HTTP via AppError::Http, HTML parse via AppError::BadInput). `get_content_call` now returns `Result<_, AppError>` instead of `Box<dyn Error>`. - rust_mention_handler: `DateTime::from_timestamp(..).unwrap()` -> `let Some(..) else { warn!; return Ok(()) }`. - gpt_service: `gpt_call` returns `Result<_, AppError>` instead of `Box<dyn Error>`; `choices[0]` index replaced with `.into_iter().next()` + fallback so an empty choices array can't panic. - chat_repository: `FromRedisValue` for ChatMessage/BotProfile now maps a serde failure to a `RedisError` instead of `.expect()`, so a corrupted Redis payload returns an error rather than crashing. The `.map_err(error!).ok()` best-effort swallows and the RUST_CHAT constant are addressed in the next iteration. No happy-path behavior change. Local gate green (fmt, clippy -D warnings, unit tests, all integration tests compile). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c001ab9 commit 172ef66

6 files changed

Lines changed: 110 additions & 69 deletions

File tree

src/boot.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::sync::{Arc, LazyLock};
22

33
use chrono::Duration;
4+
use log::error;
45
use redis::aio::ConnectionManager;
56
use regex::Regex;
67
use sqlx::{PgPool, Pool, Postgres};
@@ -15,7 +16,7 @@ use teloxide::RequestError;
1516

1617
use crate::{
1718
bf_mention_handler, chat_gpt_handler, gayness_handler, rust_mention_handler,
18-
url_summary_handler,
19+
url_summary_handler, AppError,
1920
};
2021

2122
const RUST_REGEX: &str = r"(?i)(rust|раст)(.\W|.$|\W|$)";
@@ -87,7 +88,10 @@ pub fn build_handler() -> UpdateHandler<RequestError> {
8788
db_pool: Pool<Postgres>,
8889
gpt_parameters: GptParameters,
8990
bot: Bot| async move {
90-
if let Common(MessageCommon {
91+
// Every handler returns `Result<(), AppError>`; errors are
92+
// logged once here at the dispatcher boundary and swallowed so
93+
// a single bad update never tears down the dispatcher.
94+
let outcome: Result<(), AppError> = if let Common(MessageCommon {
9195
media_kind: Text(media_text),
9296
..
9397
}) = &msg.kind
@@ -121,10 +125,12 @@ pub fn build_handler() -> UpdateHandler<RequestError> {
121125
.await
122126
}
123127
text if mention_parameters.blazing_fast_regex.is_match(text) => {
124-
bf_mention_handler::handle_bf_matched_mention(bot, msg).await
128+
bf_mention_handler::handle_bf_matched_mention(bot, msg).await;
129+
Ok(())
125130
}
126131
text if mention_parameters.gayness_regex.is_match(text) => {
127-
gayness_handler::handle_gayness_mention(bot, msg).await
132+
gayness_handler::handle_gayness_mention(bot, msg).await;
133+
Ok(())
128134
}
129135
_ => {
130136
if let Some(reply_msg) = &msg.reply_to_message() {
@@ -134,10 +140,18 @@ pub fn build_handler() -> UpdateHandler<RequestError> {
134140
reply_msg,
135141
&gpt_parameters,
136142
)
137-
.await;
143+
.await
144+
} else {
145+
Ok(())
138146
}
139147
}
140148
}
149+
} else {
150+
Ok(())
151+
};
152+
153+
if let Err(err) = outcome {
154+
error!("message handler failed: {err}");
141155
}
142156

143157
respond(())

src/chat_gpt_handler.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::sync::LazyLock;
44
use crate::chat_gpt_handler::BotProfile::{Fedor, Felix, Ferris};
55
use crate::chat_gpt_handler::ChatMessageRole::{System, User};
66
use crate::gpt_service::{ChatMessage, ChatMessageRole};
7-
use crate::{chat_repository, gpt_service, GptParameters};
7+
use crate::{chat_repository, gpt_service, AppError, GptParameters};
88
use log::{error, info};
99
use redis::aio::ConnectionManager;
1010
use regex::Regex;
@@ -66,9 +66,15 @@ static CHAT_SUMMARY_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
6666
Regex::new(SUMMARY_REQUEST_REGEX).expect("SUMMARY_REQUEST_REGEX must compile")
6767
});
6868

69-
pub async fn handle_chat_gpt_question(bot: Bot, msg: Message, gpt_parameters: &GptParameters) {
69+
pub async fn handle_chat_gpt_question(
70+
bot: Bot,
71+
msg: Message,
72+
gpt_parameters: &GptParameters,
73+
) -> Result<(), AppError> {
7074
let chat_id = msg.chat.id;
71-
let message = msg.text().expect("can't parse incoming message");
75+
let Some(message) = msg.text() else {
76+
return Ok(());
77+
};
7278
info!("gpt invocation: chat_id: {chat_id}, message: {message}");
7379
let bot_profiles = &*BOT_PROFILES;
7480
let bot_configuration = bot_profiles
@@ -124,6 +130,7 @@ pub async fn handle_chat_gpt_question(bot: Bot, msg: Message, gpt_parameters: &G
124130
bot_reply_msg_response,
125131
)
126132
.await;
133+
Ok(())
127134
}
128135

129136
async fn update_bot_context_and_identifiers(
@@ -165,9 +172,11 @@ pub async fn handle_reply(
165172
msg: &Message,
166173
reply_msg: &Message,
167174
gpt_parameters: &GptParameters,
168-
) {
175+
) -> Result<(), AppError> {
169176
info!("handle reply gpt question");
170-
let message = msg.text().expect("can't parse incoming message");
177+
let Some(message) = msg.text() else {
178+
return Ok(());
179+
};
171180
let chat_id = msg.chat.id;
172181
let chat_key = &format!("chat:{:#?}", chat_id.0);
173182
info!("chat_key: {chat_key:?}");
@@ -218,6 +227,7 @@ pub async fn handle_reply(
218227
)
219228
.await;
220229
}
230+
Ok(())
221231
}
222232

223233
async fn fetch_chat_summary_context(

src/chat_repository.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ impl ToRedisArgs for ChatMessage {
2929
impl FromRedisValue for ChatMessage {
3030
fn from_redis_value(v: &Value) -> RedisResult<Self> {
3131
let str_value: String = FromRedisValue::from_redis_value(v)?;
32-
Ok(serde_json::from_str(&str_value).expect("Can't deserialize Context as string"))
32+
serde_json::from_str(&str_value).map_err(deserialize_error::<Self>)
3333
}
3434
}
3535

@@ -45,10 +45,20 @@ impl ToRedisArgs for BotProfile {
4545
impl FromRedisValue for BotProfile {
4646
fn from_redis_value(v: &Value) -> RedisResult<Self> {
4747
let str_value: String = FromRedisValue::from_redis_value(v)?;
48-
Ok(serde_json::from_str(&str_value).expect("Can't deserialize Context as string"))
48+
serde_json::from_str(&str_value).map_err(deserialize_error::<Self>)
4949
}
5050
}
5151

52+
/// Translate a serde_json failure while reading a value back from Redis into a
53+
/// `RedisError` instead of panicking on malformed/corrupted payloads.
54+
fn deserialize_error<T>(err: serde_json::Error) -> redis::RedisError {
55+
redis::RedisError::from((
56+
redis::ErrorKind::TypeError,
57+
"Can't deserialize value from Redis",
58+
format!("{}: {err}", std::any::type_name::<T>()),
59+
))
60+
}
61+
5262
pub async fn get_chat_history(
5363
connection_manager: &mut ConnectionManager,
5464
key: i64,

src/gpt_service.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
33
use std::time::Duration;
44
use teloxide::types::ChatId;
55

6-
use crate::GptParameters;
6+
use crate::{AppError, GptParameters};
77

88
const GPT_REQUEST_TIMEOUT: Duration = Duration::from_secs(90);
99

@@ -43,14 +43,18 @@ pub async fn chat_gpt_call(
4343
chat_id: ChatId,
4444
messages: Vec<ChatMessage>,
4545
) -> ChatMessage {
46+
let fallback = || ChatMessage {
47+
role: ChatMessageRole::Assistant,
48+
content: "Братан, давай папазжей, занят сейчас.".to_owned(),
49+
};
4650
match gpt_call(params, chat_id, messages).await {
47-
Ok(choices) => choices[0].message.to_owned(),
51+
Ok(choices) => choices
52+
.into_iter()
53+
.next()
54+
.map_or_else(fallback, |choice| choice.message),
4855
Err(err) => {
4956
error!("Can't execute chat_gpt_call: {}", err);
50-
ChatMessage {
51-
role: ChatMessageRole::Assistant,
52-
content: "Братан, давай папазжей, занят сейчас.".to_owned(),
53-
}
57+
fallback()
5458
}
5559
}
5660
}
@@ -59,7 +63,7 @@ async fn gpt_call(
5963
params: &GptParameters,
6064
chat_id: ChatId,
6165
messages: Vec<ChatMessage>,
62-
) -> Result<Vec<Choice>, Box<dyn std::error::Error>> {
66+
) -> Result<Vec<Choice>, AppError> {
6367
info!(
6468
"gpt call invocation from chat_id: {} with context: {:#?}",
6569
chat_id, messages

src/rust_mention_handler.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use chrono::{DateTime, Duration, TimeZone, Utc};
2-
use log::{error, info};
2+
use log::{error, info, warn};
33
use sqlx::PgPool;
44
use teloxide::prelude::*;
55
use teloxide::types::{InputFile, MessageId, ReplyParameters, ThreadId, User};
66

7-
use crate::mention_repository;
7+
use crate::{mention_repository, AppError};
88

99
const RUST_CHAT: i64 = -1001228598755;
1010
const STICKERS: &[&str; 5] = &[
@@ -22,9 +22,12 @@ pub async fn handle_rust_matched_mention(
2222
message: Message,
2323
db_pool: PgPool,
2424
req_time_diff: Duration,
25-
) {
25+
) -> Result<(), AppError> {
2626
let message_date = message.date.timestamp();
27-
let curr_date = DateTime::from_timestamp(message_date, 0).unwrap();
27+
let Some(curr_date) = DateTime::from_timestamp(message_date, 0) else {
28+
warn!("skipping rust mention: nonsensical message timestamp {message_date}");
29+
return Ok(());
30+
};
2831
info!(
2932
"rust mention invocation: chat_id: {}, time: {}",
3033
message.chat.id, curr_date
@@ -69,6 +72,7 @@ pub async fn handle_rust_matched_mention(
6972
.ok();
7073
}
7174
}
75+
Ok(())
7276
}
7377

7478
async fn send_rust_mention_response(

src/url_summary_handler.rs

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::gpt_service::ChatMessage;
22
use crate::gpt_service::ChatMessageRole::{System, User};
3-
use crate::{gpt_service, GptParameters};
3+
use crate::{gpt_service, AppError, GptParameters};
44
use log::{error, info};
55
use regex::Regex;
66
use reqwest::Client;
@@ -19,53 +19,55 @@ pub async fn handle_url_summary(
1919
msg: Message,
2020
url_regex: Regex,
2121
gpt_parameters: &GptParameters,
22-
) {
23-
if let Common(MessageCommon {
22+
) -> Result<(), AppError> {
23+
let Common(MessageCommon {
2424
media_kind: Text(media_text),
2525
..
2626
}) = msg.kind
27-
{
28-
let msg_text = &media_text.text;
29-
let chat_id = msg.chat.id;
30-
info!(
31-
"url summary invocation: chat_id: {}, msg {}",
32-
chat_id, msg_text
33-
);
34-
let url = url_regex
35-
.find(msg_text)
36-
.map(|m| m.as_str().to_string())
37-
.or(find_link(&media_text));
38-
let Some(url) = url else {
39-
info!("No URL found in message: {}", msg_text);
40-
return;
41-
};
27+
else {
28+
return Ok(());
29+
};
4230

43-
let content = get_content_call(&gpt_parameters.http_client, &url)
44-
.await
45-
.unwrap();
46-
let clean_content = html2text::from_read(content.as_bytes(), 120).unwrap();
47-
// Check if the content is long enough to summarize
48-
if clean_content.len() < 1000 {
49-
return;
50-
}
51-
let summary = get_gpt_summary(gpt_parameters, chat_id, clean_content).await;
31+
let msg_text = &media_text.text;
32+
let chat_id = msg.chat.id;
33+
info!(
34+
"url summary invocation: chat_id: {}, msg {}",
35+
chat_id, msg_text
36+
);
37+
let url = url_regex
38+
.find(msg_text)
39+
.map(|m| m.as_str().to_string())
40+
.or(find_link(&media_text));
41+
let Some(url) = url else {
42+
info!("No URL found in message: {}", msg_text);
43+
return Ok(());
44+
};
5245

53-
let reply_msg = bot
54-
.send_message(chat_id, format!("TLDR:\n{}", summary))
55-
.reply_parameters(ReplyParameters::new(msg.id));
56-
if let Some(thread_id) = msg.thread_id {
57-
reply_msg
58-
.message_thread_id(thread_id)
59-
.await
60-
.map_err(|err| error!("Can't send reply: {:?}", err))
61-
.ok();
62-
} else {
63-
reply_msg
64-
.await
65-
.map_err(|err| error!("Can't send reply: {:?}", err))
66-
.ok();
67-
}
46+
let content = get_content_call(&gpt_parameters.http_client, &url).await?;
47+
let clean_content = html2text::from_read(content.as_bytes(), 120)
48+
.map_err(|err| AppError::BadInput(format!("failed to parse article HTML: {err}")))?;
49+
// Check if the content is long enough to summarize
50+
if clean_content.len() < 1000 {
51+
return Ok(());
52+
}
53+
let summary = get_gpt_summary(gpt_parameters, chat_id, clean_content).await;
54+
55+
let reply_msg = bot
56+
.send_message(chat_id, format!("TLDR:\n{}", summary))
57+
.reply_parameters(ReplyParameters::new(msg.id));
58+
if let Some(thread_id) = msg.thread_id {
59+
reply_msg
60+
.message_thread_id(thread_id)
61+
.await
62+
.map_err(|err| error!("Can't send reply: {:?}", err))
63+
.ok();
64+
} else {
65+
reply_msg
66+
.await
67+
.map_err(|err| error!("Can't send reply: {:?}", err))
68+
.ok();
6869
}
70+
Ok(())
6971
}
7072

7173
fn find_link(media_text: &MediaText) -> Option<String> {
@@ -82,10 +84,7 @@ fn find_link(media_text: &MediaText) -> Option<String> {
8284
.find_map(|el| el)
8385
}
8486

85-
async fn get_content_call(
86-
client: &Client,
87-
url: &str,
88-
) -> Result<String, Box<dyn std::error::Error>> {
87+
async fn get_content_call(client: &Client, url: &str) -> Result<String, AppError> {
8988
let response = client
9089
.get(url)
9190
.timeout(ARTICLE_EXTRACTION_TIMEOUT)

0 commit comments

Comments
 (0)