Skip to content

fix: add timeout to health check endpoints - #15

Closed
buzzkillb wants to merge 9 commits into
mainfrom
fix/health-timeout
Closed

fix: add timeout to health check endpoints#15
buzzkillb wants to merge 9 commits into
mainfrom
fix/health-timeout

Conversation

@buzzkillb

@buzzkillb buzzkillb commented Apr 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Added 5 second timeout to /health and /health/all endpoints
  • If health check takes longer than 5s, returns unhealthy instead of hanging indefinitely
  • This prevents Docker health checks from timing out when the health server hangs

Problem

The health server was hanging when calling to_json() which iterates all bots and builds JSON. Docker health checks would timeout after 10s, marking the container unhealthy even though all bots were running fine.

Solution

  • Added tokio::time::timeout to both health check handlers
  • If the health check doesn't complete within 5 seconds, returns healthy: false with an error message
  • This ensures Docker health checks always complete in a timely manner

Files Changed

  • src/health_server.rs - Added timeout wrapper to health check handlers

Note

Medium Risk

Overview
This pull request adds a 5-second timeout to the /health and /health/all endpoints to prevent the health check from hanging indefinitely when the underlying operations (such as iterating bots and building JSON) take too long. Previously, the health server could hang when calling to_json(), causing Docker health checks to timeout after 10 seconds and incorrectly mark the container as unhealthy. The fix returns an unhealthy status immediately if the health check exceeds the timeout threshold, ensuring the container remains responsive to Docker's health monitoring.

Written by Gitzilla for commit bd70916. This will update automatically on new runs. Configure in the Gitzilla dashboard.

- Added 5 second timeout to /health and /health/all endpoints
- If health check takes longer than 5s, returns unhealthy instead of hanging
- Refactored handlers to use timeout utility from tokio::time
- Fixed return type issue where Err variant had wrong format
…egator

The std::sync::Mutex was causing the async tokio runtime to block when
the health check tried to acquire the lock, leading to health check
timeouts and Discord gateway disconnects. Changed to tokio::sync::Mutex
which properly yields to the scheduler when contended.

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread src/health.rs
Comment on lines +196 to +202
pub async fn add_bot(&self, health: Arc<HealthState>) {
if let Ok(mut bots) = self.bots.lock().await {
bots.push(health);
}
}

pub fn is_healthy(&self) -> bool {
if let Ok(bots) = self.bots.lock() {
pub async fn is_healthy(&self) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_healthy() changed from ANY to ALL, contradicting its documented behavior

High Severity

The is_healthy() method in src/health.rs was changed from using any() to all(). The method comment states "Returns healthy if at least one bot is functioning," but the code now returns healthy only if ALL bots are functioning. This is a behavioral regression: a single unhealthy bot will now make the entire service report unhealthy, contrary to the documented intent of checking "at least one bot." This breaks the Docker health check use case where partial availability should still be considered healthy.

Suggested fix: Revert the all() change back to any(): return bots.iter().any(|b| b.is_healthy());

Comment thread src/health_server.rs
Comment on lines +46 to +59
async move { health.is_healthy().await },
)
.await;

let is_healthy = result.unwrap_or(false);

let response = serde_json::json!({
"healthy": is_healthy
});

if is_healthy {
Ok(Json(response))
(StatusCode::OK, Json(response)).into_response()
} else {
Err(StatusCode::SERVICE_UNAVAILABLE)
(StatusCode::SERVICE_UNAVAILABLE, Json(response)).into_response()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeout returns 500 instead of 503 for /health endpoint

Medium Severity

In health_check, when the 5-second timeout elapses, the code falls through to is_healthy = false and returns a 503 status code via StatusCode::SERVICE_UNAVAILABLE. However, the comment and behavior suggest this should be a distinct timeout error. The health_check_all function explicitly sets is_all_healthy: false and includes "timeout": "Health check timed out after 5 seconds" in its response on timeout, which is more informative. The /health endpoint should provide similar timeout feedback rather than silently returning unhealthy.

Suggested fix: Add a timeout flag to differentiate timeout from genuine unhealthiness, similar to health_check_all's approach, and include a descriptive message in the response.

- Fix periodic Discord connectivity test: was mathematically impossible (mod 4 vs mod 10)
- Fix loop exit logic: return Err in async block didn't exit loop, use break instead
- Fix fire-and-forget periodic test: now awaits and checks failures synchronously
- Fix Mutex API usage: tokio::sync::Mutex returns guard directly, not Result
- Add time-based connectivity check every 60 seconds
- Add restart triggers: gateway_failures>=5, discord_test_failures>=3, no Discord success >2min
- Remove unreachable!() panic in format_custom_status

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread src/health.rs
Comment on lines +192 to +201
bots: Arc::new(Mutex::new(Vec::new())),
}
}

pub fn add_bot(&self, health: Arc<HealthState>) {
if let Ok(mut bots) = self.bots.lock() {
bots.push(health);
}
pub async fn add_bot(&self, health: Arc<HealthState>) {
let mut bots = self.bots.lock().await;
bots.push(health);
}

pub fn is_healthy(&self) -> bool {
if let Ok(bots) = self.bots.lock() {
if bots.is_empty() {
return true;
}
return bots.iter().any(|b| b.is_healthy());
pub async fn is_healthy(&self) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_healthy() behavior changed from ANY to ALL

High Severity

The is_healthy() method in HealthAggregator was refactored from checking bots.iter().any(|b| b.is_healthy()) to bots.iter().all(|b| b.is_healthy()). The comment above HealthAggregator documents it should "Return healthy if at least one bot is functioning" which matches the original any() behavior, not the new all() behavior. Now is_healthy() and is_all_healthy() have identical implementations—both return true only when all bots are healthy. This contradicts the documented intent and changes the semantics of the /health endpoint from "at least one bot is running" to "every bot is running", which could cause Docker to mark containers unhealthy when only a subset of bots are functioning.

Suggested fix: Restore the original any() logic: bots.iter().any(|b| b.is_healthy()) in is_healthy(), or clarify if the documented behavior is intentionally changing.

Change % 4 to % 40 for update_count to allow periodic connectivity test
to trigger, but update all match expressions to use update_count % 4
so they cycle properly instead of panicking on values 4-39

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread docker-compose.yml
interval: 30s
timeout: 10s
retries: 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docker healthcheck removed, breaking container orchestration

Medium Severity

The docker-compose.yml had a healthcheck configuration that automatically tested /health/all every 30 seconds with a 10s timeout. This entire block was deleted. Without a healthcheck, Docker cannot automatically restart unhealthy containers or report their health status. The PR goal was to fix health check timeouts, but removing the healthcheck entirely eliminates Docker's ability to perform automated health monitoring. If the 5-second timeout added to the health server is meant to replace Docker's 10-second timeout, the healthcheck should remain but be adjusted (not deleted).

Suggested fix: Restore the healthcheck configuration with an adjusted timeout: timeout: 8s (accounting for the 5-second internal timeout plus response overhead).

Comment thread src/health_server.rs
Comment on lines +46 to +57
async move { health.is_healthy().await },
)
.await;

let is_healthy = result.unwrap_or(false);

let response = serde_json::json!({
"healthy": is_healthy
});

if is_healthy {
Ok(Json(response))
(StatusCode::OK, Json(response)).into_response()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent health status between JSON body and HTTP status code

Medium Severity

The to_json() method still uses any_healthy logic, meaning the JSON body will report "healthy": true if any single bot is functioning. However, the /health endpoint now calls is_healthy() which uses all() logic. If some bots are healthy but not all, Docker/health checks will receive HTTP 503 but a JSON body with healthy: true. This contradiction makes debugging difficult and the JSON response misleading about the actual endpoint status.

Suggested fix: Ensure consistency: either update to_json() to also use all() semantics to match the /health endpoint, or have the endpoint use the JSON's healthy field as the source of truth for both the response body and HTTP status.

SQLite doesn't support DELETE ... LIMIT until version 3.35.0.
Wrap LIMIT in subquery to delete by rowid instead.

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread src/health.rs
Comment on lines +201 to +206
pub async fn is_healthy(&self) -> bool {
let bots = self.bots.lock().await;
if bots.is_empty() {
return true;
}
false
bots.iter().all(|b| b.is_healthy())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Health semantics silently changed from ANY to ALL

High Severity

The is_healthy() method in HealthAggregator was changed from returning true if ANY bot is healthy (using .any()) to returning true only if ALL bots are healthy (using .all()). The comment at line 183 still says "Returns healthy if at least one bot is functioning" but this is no longer true.

The old behavior was:

  • /health: healthy if at least one bot works (resilient)
  • /health/all: healthy only if all bots work

The new behavior:

  • /health: healthy only if ALL bots work (fragile - one failing bot makes container unhealthy)

This is a behavioral regression. If a single bot fails (e.g., one Discord token expires), the entire container will report unhealthy even though other bots continue functioning. The PR title says "add timeout" but this semantic change was not mentioned in the PR description.

Suggested fix: Restore is_healthy() to use .any() to match the documented behavior: "Returns healthy if at least one bot is functioning". The /health endpoint should be resilient - returning healthy when at least one bot works.

Comment thread src/health.rs
Comment on lines +201 to +206
pub async fn is_healthy(&self) -> bool {
let bots = self.bots.lock().await;
if bots.is_empty() {
return true;
}
false
bots.iter().all(|b| b.is_healthy())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poisoned Mutex behavior changed - graceful degradation lost

Medium Severity

The old is_healthy() method handled lock acquisition failure gracefully:

if let Ok(bots) = self.bots.lock() { ... }
false  // returns false if lock poisoned

The new async version uses self.bots.lock().await which will panic if the mutex is poisoned. While mutex poisoning is rare, this removes a defensive fallback that previously prevented crashes in edge cases.

Suggested fix: Consider using Mutex::lock() with proper error handling or tokio::sync::Mutex::lock().await with a expect/unwrap that documents the intentional panic behavior. If graceful degradation is desired, wrap in try-lock or handle poison.

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread src/database.rs Outdated
Comment on lines +82 to +89
let pool = cfg
.create_pool(Some(Runtime::Tokio1), NoTls)
.map_err(|e| BotError::Database(tokio_postgres::Error::__private_api_timeout()))?;

let conn = pool
.get()
.map_err(|e| BotError::Database(rusqlite::Error::ToSqlConversionFailure(Box::new(e))))
.await
.map_err(|e| BotError::Database(tokio_postgres::Error::__private_api_timeout()))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pool errors masked with fabricated timeout errors

High Severity

In db_cleanup.rs line 30 and database.rs lines 84 and 89, pool connection failures are converted to fake timeout errors via tokio_postgres::Error::__private_api_timeout(). The actual error e from pool.get().await is discarded. This loses all real error information (connection refused, authentication failed, pool exhausted, etc.) and incorrectly reports every failure as a timeout. Additionally, __private_api_timeout is a private unstable API that could break in future tokio-postgres versions.

Suggested fix: Use the actual error: .map_err(|e| BotError::Database(e.to_string())) or create a proper error variant that preserves the error context.

@buzzkillb
buzzkillb force-pushed the fix/health-timeout branch from 416506d to 4a1c65f Compare April 19, 2026 19:08
…bots

- Increased busy_timeout to 60s
- Increased pool size from 4 to 16 connections
- Added min_idle of 4 connections
- Added 64MB cache
- Added temp_store = MEMORY

Note: PostgreSQL migration attempted but requires async rewrite of all call sites
- Replace rusqlite/r2d2 with async sqlx and PgPool
- All database methods are now async fn
- Add PostgreSQL service to docker-compose.yml with healthcheck
- DATABASE_URL env var replaces DATABASE_PATH
- Update all code to use .await for database calls
- PostgreSQL eliminates 'database is locked' errors with 24+ bots
Issue 4 - DB Write Failures:
- Add db_failures counter to HealthState
- Increment on DB write failure, reset on success
- Mark unhealthy if db_failures > 3

Issue 5 - Health Server Bind Failure:
- Add start_health_server_with_retry() with 10 retries
- Health server bind failure is now non-fatal
- Bots continue running even if health server fails

Issues 1 & 6 - Silent Task Death and Supervision:
- All spawned tasks now have supervision loops
- Services auto-restart on unexpected exit
- Store and monitor all service join handles
- Log CRITICAL errors when services die unexpectedly

Issue 2 - Race Condition on prices.json:
- Add SharedPrices with tokio::sync::RwLock
- Price service writes to shared state atomically first
- Bots read from shared state instead of file
- File write happens AFTER atomic state update

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 2 potential issues.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

Comment thread src/main.rs
Comment on lines +61 to +73
let db = match PriceDatabase::new(&config::DATABASE_URL).await {
Ok(db) => Arc::new(db),
Err(e) => {
error!("Failed to initialize database: {}", e);
return Err(e);
}
};

// Start Database Cleanup Service
// Create shared price state for all services and bots
let shared_prices = Arc::new(SharedPrices::new());

// Create health aggregator for all bots
let health_aggregator = Arc::new(HealthAggregator::new());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service supervision deadlocks on critical errors

High Severity

In src/main.rs, the service supervision loop restarts services with a fixed 5-second delay regardless of the error type. When cleanup.run().await returns Ok(()) (which happens when the cleanup cycle completes normally), the service still gets restarted after the delay. Since DatabaseCleanup is designed to run forever in a loop, receiving Ok(()) means it exited successfully rather than crashed—but the supervisor treats this identically to a crash and immediately restarts it. This creates an infinite restart cycle that wastes resources and potentially causes database contention as multiple cleanup instances compete.

Suggested fix: The cleanup service returns Ok(()) when it completes a cleanup cycle normally. The supervisor should distinguish between normal completion and actual crashes by checking whether the service returned an error, or restructure the service so run() never returns unless there's a genuine error.

Comment thread src/bot.rs
use crate::discord_api::DiscordApi;
use crate::errors::{BotError, BotResult};
use crate::health::{HealthAggregator, HealthState};
use crate::price_state::SharedPrices;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discord connectivity check blocks price update loop

Medium Severity

The Discord connectivity test in price_update_loop is now called directly with .await instead of in a spawned background task. This causes the entire price update loop to block for the duration of the HTTP call (typically several seconds) on every connectivity check cycle (every 60 seconds). The original code spawned it as tokio::spawn(async move { test_discord_connectivity(health_clone).await; }) to avoid blocking. With a 12-second update interval, this can cause updates to be delayed by the full duration of the network call, degrading the responsiveness of price and nickname updates.

Suggested fix: Restore the spawn pattern: tokio::spawn(async move { test_discord_connectivity(health_clone).await; });

- Fix syntax error in database.rs (extra parenthesis)
- Fix Arc/SharedPrices type mismatches across modules
- Fix i64 vs u64 type casts in db_cleanup.rs
- Fix duplicate Arc import in price_service.rs
- Fix private struct re-exports from price_state
- Fix ticker move before use in async spawn loop
- Fix health_server return type in match arm

@gitzillabot gitzillabot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitzilla has reviewed your changes and found 1 potential issue.

Autofix is OFF. To automatically fix reported issues, enable autofix in the Gitzilla dashboard.

.insert(CRYPTO_NAME.to_string(), price_data.clone());
shared_prices.write(shared_prices_data).await;
info!("📝 Updated shared prices state");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent file writes cause price data loss

High Severity

The shanghai_price_service and price_service both write to shared/prices.json without coordination, creating a read-modify-write race condition. In update_prices_json (shanghai_price_service.rs:126-145), the function reads the existing file, merges in new data, and writes back. If price_service.write_prices_to_file overwrites the file between the read and write, shanghai_service's merged data will overwrite all other prices, causing silent data loss. This affects BTC, ETH, SOL, DXY and other prices tracked by the main price service.

Suggested fix: Use atomic file writes (write to temp file then rename) or coordinate file writes via the SharedPrices RwLock. Alternatively, have only one service write to the JSON file and read-only access for the other.

@buzzkillb buzzkillb closed this Apr 20, 2026
@buzzkillb
buzzkillb deleted the fix/health-timeout branch April 20, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant