Skip to content

Commit 7bda853

Browse files
authored
fix: add /health/all endpoint for per-bot health monitoring (#14)
* Simplify health/status responses and upgrade image crate to 0.25 * Fix image 0.25 API: use encoder.write_image instead of deprecated write_with_encoder * Fix: SHANGHAI premium display, BTC rotation, HTML parsing fallback, semaphore in struct, health server bind failures * Fix division by zero, add price validation, add uniqueness constraint on prices * Use execute instead of prepare_cached for INSERT OR REPLACE * Fix: use config.update_interval for rotation, add is_fallback flag to PriceData * Fix unreachable code, use is_fallback for stale indicator * Fix: division by zero guard, update fallback prices, add chart range guard * Fix: semaphore rate limiting, integer underflow, unreachable panics, silent error swallowing, division by zero * Security: safer regex, sanitize error messages for users, reduce DB pool size * fix: add /health/all endpoint for per-bot health monitoring - Added is_all_healthy() method that returns true only when ALL bots are healthy - Added /health/all endpoint that returns 503 if any bot is unhealthy - Updated Docker health check to use /health/all instead of /health - This ensures container restarts when any bot (e.g. JLP) drops offline
1 parent 94e1ac4 commit 7bda853

10 files changed

Lines changed: 287 additions & 174 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ services:
2121
max-size: "10m"
2222
max-file: "3"
2323
healthcheck:
24-
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
24+
test: ["CMD", "curl", "-f", "http://localhost:8080/health/all"]
2525
interval: 30s
2626
timeout: 10s
2727
retries: 3

src/bot.rs

Lines changed: 119 additions & 103 deletions
Large diffs are not rendered by default.

src/charting.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,15 @@ pub fn generate_price_chart(
173173
.map(|(_, p)| *p)
174174
.fold(f64::NEG_INFINITY, f64::max);
175175

176-
// Add padding
177-
let range_padding = (max_price - min_price) * 0.05;
178-
let y_min = min_price - range_padding;
179-
let y_max = max_price + range_padding;
176+
// Ensure minimum range for Y axis
177+
let price_range = (max_price - min_price).abs();
178+
let (y_min, y_max) = if price_range < 0.0001 {
179+
(min_price - 1.0, max_price + 1.0)
180+
} else {
181+
// Add padding
182+
let range_padding = price_range * 0.05;
183+
(min_price - range_padding, max_price + range_padding)
184+
};
180185

181186
let mut chart = ChartBuilder::on(&root)
182187
.caption(title, text_style)

src/database.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ impl PriceDatabase {
3333
timestamp INTEGER NOT NULL,
3434
created_at TEXT DEFAULT CURRENT_TIMESTAMP
3535
);
36+
CREATE UNIQUE INDEX IF NOT EXISTS idx_prices_crypto_timestamp_unique ON prices(crypto_name, timestamp);
3637
CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp ON prices(crypto_name, timestamp);
3738
CREATE TABLE IF NOT EXISTS price_aggregates (
3839
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -53,7 +54,7 @@ impl PriceDatabase {
5354
});
5455

5556
let pool = Pool::builder()
56-
.max_size(10) // Allow up to 10 concurrent connections
57+
.max_size(4) // SQLite performs better with fewer connections
5758
.build(manager)
5859
.map_err(|e| {
5960
BotError::Database(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))
@@ -83,11 +84,10 @@ impl PriceDatabase {
8384
let conn = self.get_connection()?;
8485
let current_time = get_current_timestamp()?;
8586

86-
let mut stmt = conn.prepare_cached(
87-
"INSERT INTO prices (crypto_name, price, timestamp) VALUES (?, ?, ?)",
87+
conn.execute(
88+
"INSERT OR REPLACE INTO prices (crypto_name, price, timestamp) VALUES (?1, ?2, ?3)",
89+
[crypto_name, &price.to_string(), &current_time.to_string()],
8890
)?;
89-
90-
stmt.execute([crypto_name, &price.to_string(), &current_time.to_string()])?;
9191
debug!("Saved {} price to database: ${}", crypto_name, price);
9292
Ok(())
9393
}
@@ -163,7 +163,16 @@ impl PriceDatabase {
163163
];
164164

165165
for (seconds, label) in periods {
166-
let time_ago = current_time - seconds;
166+
// Guard against underflow if clock goes backwards
167+
let time_ago = if current_time >= seconds {
168+
current_time - seconds
169+
} else {
170+
debug!(
171+
"Clock appears to have gone backwards, skipping {} price lookup",
172+
label
173+
);
174+
continue;
175+
};
167176

168177
// Try to get price from appropriate data source based on age
169178
let old_price = if seconds <= 24 * 3600 {

src/discord_api.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,34 @@ const RATE_LIMIT_DELAY_MS: u64 = 2000; // 2 seconds between Discord API calls
1515
#[derive(Clone)]
1616
pub struct DiscordApi {
1717
http: Arc<Http>,
18+
semaphore: Arc<Semaphore>,
1819
}
1920

2021
impl DiscordApi {
2122
pub fn new(http: Arc<Http>) -> Self {
22-
Self { http }
23+
Self {
24+
http,
25+
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_CALLS)),
26+
}
2327
}
2428

2529
/// Rate-limited Discord API call helper
30+
#[allow(unused_variables)]
2631
async fn rate_limited_call<F, Fut, T>(&self, mut operation: F) -> Result<T, serenity::Error>
2732
where
2833
F: FnMut() -> Fut,
2934
Fut: std::future::Future<Output = Result<T, serenity::Error>>,
3035
{
31-
static SEMAPHORE: Semaphore = Semaphore::const_new(MAX_CONCURRENT_CALLS);
32-
33-
let _permit = SEMAPHORE
36+
let permit = self
37+
.semaphore
3438
.acquire()
3539
.await
3640
.map_err(|_| serenity::Error::Other("Semaphore acquire error"))?;
3741

3842
// Enforce minimum delay between calls
3943
sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await;
4044

41-
// Execute the operation with retry logic
45+
// Execute the operation with retry logic - permit is held during all attempts
4246
for attempt in 1..=MAX_RETRIES {
4347
match operation().await {
4448
Ok(result) => return Ok(result),
@@ -60,7 +64,7 @@ impl DiscordApi {
6064
}
6165
}
6266

63-
unreachable!()
67+
Err(serenity::Error::Other("Rate limiting exhausted"))
6468
}
6569

6670
/// Update bot nickname in a specific guild

src/errors.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@ pub enum BotError {
3333
Parse(String),
3434
}
3535

36+
impl BotError {
37+
pub fn user_message(&self) -> &'static str {
38+
match self {
39+
BotError::PriceNotFound(_) => "Price data not available for this asset",
40+
BotError::InvalidInput(_) => "Invalid input provided",
41+
BotError::Discord(_) => "Discord API error - please try again",
42+
BotError::EnvVar(_) => "Configuration error - please contact support",
43+
// Internal errors - don't expose details to users
44+
BotError::Database(_)
45+
| BotError::Http(_)
46+
| BotError::Json(_)
47+
| BotError::SystemTime(_)
48+
| BotError::Io(_)
49+
| BotError::Parse(_) => "An internal error occurred. Please try again later.",
50+
}
51+
}
52+
}
53+
3654
impl From<std::env::VarError> for BotError {
3755
fn from(err: std::env::VarError) -> Self {
3856
BotError::EnvVar(err.to_string())
@@ -51,4 +69,4 @@ impl From<std::num::ParseFloatError> for BotError {
5169
}
5270
}
5371

54-
pub type BotResult<T> = Result<T, BotError>;
72+
pub type BotResult<T> = Result<T, BotError>;

src/health.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ impl HealthAggregator {
208208
false
209209
}
210210

211+
pub fn is_all_healthy(&self) -> bool {
212+
if let Ok(bots) = self.bots.lock() {
213+
if bots.is_empty() {
214+
return true;
215+
}
216+
return bots.iter().all(|b| b.is_healthy());
217+
}
218+
false
219+
}
220+
211221
pub fn to_json(&self) -> serde_json::Value {
212222
let bots = match self.bots.lock() {
213223
Ok(bots) => bots,

src/health_server.rs

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,28 @@ use tracing::{error, info};
66

77
pub type SharedHealth = Arc<HealthAggregator>;
88

9-
pub async fn start_health_server(health: SharedHealth, port: u16) {
9+
pub async fn start_health_server(
10+
health: SharedHealth,
11+
port: u16,
12+
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1013
let app = Router::new()
1114
.route("/health", get(health_check))
15+
.route("/health/all", get(health_check_all))
1216
.route("/", get(health_check))
1317
.route("/test-discord", get(test_discord_connectivity))
1418
.with_state(health);
1519

1620
let addr = format!("127.0.0.1:{}", port);
1721

18-
match TcpListener::bind(&addr).await {
19-
Ok(listener) => {
20-
info!("Health check server listening on {}", addr);
21-
if let Err(e) = axum::serve(listener, app).await {
22-
error!("Health server error: {}", e);
23-
}
24-
}
25-
Err(e) => {
26-
error!("Failed to bind health server to {}: {}", addr, e);
27-
}
28-
}
22+
let listener = TcpListener::bind(&addr).await.map_err(|e| {
23+
error!("Failed to bind health server to {}: {}", addr, e);
24+
e
25+
})?;
26+
27+
info!("Health check server listening on {}", addr);
28+
axum::serve(listener, app).await?;
29+
30+
Ok(())
2931
}
3032

3133
async fn health_check(
@@ -44,6 +46,19 @@ async fn health_check(
4446
}
4547
}
4648

49+
async fn health_check_all(
50+
State(health): State<SharedHealth>,
51+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
52+
let is_all_healthy = health.is_all_healthy();
53+
let status = health.to_json();
54+
55+
if is_all_healthy {
56+
Ok(Json(status))
57+
} else {
58+
Err((StatusCode::SERVICE_UNAVAILABLE, Json(status)))
59+
}
60+
}
61+
4762
async fn test_discord_connectivity(
4863
State(_health): State<SharedHealth>,
4964
) -> Result<Json<serde_json::Value>, StatusCode> {

src/main.rs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
1-
mod errors;
1+
mod bot;
2+
mod charting;
23
mod config;
3-
mod utils;
4-
mod health;
5-
mod health_server;
64
mod database;
5+
#[cfg(test)]
6+
mod database_tests;
7+
mod db_cleanup;
78
mod discord_api;
8-
mod bot;
9+
mod errors;
10+
mod health;
11+
mod health_server;
912
mod price_service;
10-
mod db_cleanup;
11-
mod charting;
1213
mod shanghai_price_service;
13-
#[cfg(test)]
14-
mod database_tests;
14+
mod utils;
1515

16-
use errors::BotResult;
16+
use bot::start_bot;
1717
use config::BotConfig;
1818
use database::PriceDatabase;
1919
use db_cleanup::DatabaseCleanup;
20-
use bot::start_bot;
21-
use health::{HealthState, HealthAggregator};
20+
use errors::BotResult;
21+
use health::{HealthAggregator, HealthState};
2222
use health_server::start_health_server;
2323

2424
use dotenv::dotenv;
2525
use std::sync::Arc;
2626
use std::time::Duration;
2727
use tokio::time::sleep;
28-
use tracing::{info, error, warn};
28+
use tracing::{error, info, warn};
2929

3030
const RECONNECT_DELAY_SECONDS: u64 = 30;
3131

@@ -35,10 +35,10 @@ async fn main() -> BotResult<()> {
3535
tracing_subscriber::fmt()
3636
.with_env_filter("info,discord_bot=debug,discord_bot::database=info")
3737
.init();
38-
38+
3939
info!("🚀 Starting RustyMcPriceface Unified Container...");
4040
dotenv().ok();
41-
41+
4242
// Initialize shared database
4343
info!("📦 Initializing shared database...");
4444
let db = match PriceDatabase::new(config::DATABASE_PATH) {
@@ -110,7 +110,7 @@ async fn main() -> BotResult<()> {
110110
// Create health state for this bot and register with aggregator
111111
let health = Arc::new(HealthState::new(ticker.clone()));
112112
let health_clone = health.clone();
113-
113+
114114
// Add to aggregator
115115
health_agg_clone.add_bot(health);
116116

@@ -122,16 +122,26 @@ async fn main() -> BotResult<()> {
122122
let emoji = utils::get_crypto_emoji(&ticker);
123123
info!("{} Starting {} bot...", emoji, ticker);
124124

125-
match start_bot(bot_config.clone(), db_clone.clone(), health_clone.clone(), health_agg_clone.clone()).await {
125+
match start_bot(
126+
bot_config.clone(),
127+
db_clone.clone(),
128+
health_clone.clone(),
129+
health_agg_clone.clone(),
130+
)
131+
.await
132+
{
126133
Ok(_) => {
127134
error!("{} {} bot exited unexpectedly", emoji, ticker);
128-
},
135+
}
129136
Err(e) => {
130137
error!("{} {} bot crashed: {}", emoji, ticker, e);
131138
}
132139
}
133140

134-
error!("{} Restarting {} bot in {} seconds...", emoji, ticker, RECONNECT_DELAY_SECONDS);
141+
error!(
142+
"{} Restarting {} bot in {} seconds...",
143+
emoji, ticker, RECONNECT_DELAY_SECONDS
144+
);
135145
sleep(Duration::from_secs(RECONNECT_DELAY_SECONDS)).await;
136146
}
137147
});
@@ -142,7 +152,10 @@ async fn main() -> BotResult<()> {
142152
info!("🏥 Starting health check server...");
143153
let health_for_server = health_aggregator.clone();
144154
tokio::spawn(async move {
145-
start_health_server(health_for_server, 8080).await;
155+
if let Err(e) = start_health_server(health_for_server, 8080).await {
156+
error!("❌ Health server failed: {}", e);
157+
panic!("Health server must start successfully for container health checks");
158+
}
146159
});
147160

148161
// Give health server time to start
@@ -158,6 +171,6 @@ async fn main() -> BotResult<()> {
158171
} else {
159172
warn!("⚠️ No bots to run. Exiting.");
160173
}
161-
174+
162175
Ok(())
163-
}
176+
}

0 commit comments

Comments
 (0)