Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7fec9ae
Initial plan
Copilot Apr 14, 2026
2071551
feat: add HTTP healthcheck endpoint for bot runtime checks
Copilot Apr 14, 2026
3146615
chore: polish healthcheck shutdown exception naming
Copilot Apr 14, 2026
7543163
Address PR feedback on healthcheck imports and probe checks
Copilot Apr 14, 2026
0823976
Refine healthcheck probes per PR feedback
Copilot Apr 14, 2026
e0ba7f9
Simplify import
ToothyDev Apr 14, 2026
dd82e39
Tighten heartbeat latency health threshold
Copilot Apr 14, 2026
8bdd1db
Add healthcheck module and method docstrings
Copilot Apr 14, 2026
eb4504d
Expand healthcheck module docstring scope
Copilot Apr 14, 2026
863b088
Align module docstring ratelimit terminology
Copilot Apr 14, 2026
0324a43
Refactor healthcheck startup into cog and add Docker HEALTHCHECK
Copilot Apr 18, 2026
9943694
Parameterize Docker healthcheck and cog name constant
Copilot Apr 18, 2026
671bea8
Switch Docker HEALTHCHECK to wget probe
Copilot Apr 18, 2026
372b9e5
Apply healthcheck review changes for Docker defaults and endpoint path
Copilot Apr 18, 2026
e9781b5
Allow disabling healthcheck via empty host and remove app-side defaults
Copilot Apr 19, 2026
1f61ca7
Refine healthcheck env typing and clarify disable docs
Copilot Apr 19, 2026
85443ee
Clarify env variable name in healthcheck port error
Copilot Apr 19, 2026
e7d5a49
Include configured host value in healthcheck port error
Copilot Apr 19, 2026
96b148c
Validate HEALTHCHECK_PORT in cog with explicit runtime errors
Copilot Apr 19, 2026
0d8ec97
Deduplicate healthcheck port validation error message
Copilot Apr 19, 2026
e2e2cfb
Differentiate missing vs invalid HEALTHCHECK_PORT errors
Copilot Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
TOKEN="YOUR BOT TOKEN HERE"
TOKEN="YOUR BOT TOKEN HERE"
HEALTHCHECK_HOST="127.0.0.1"
HEALTHCHECK_PORT="8080"
HEALTHCHECK_PATH="/"
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ RUN pip install -r requirements.txt
COPY src/ ./src
USER appuser

EXPOSE 8080

CMD ["python", "-m", "src"]
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,22 @@ Versa is a simple utility Discord bot, with the main goal of being open source a

#### ⚠ **Further support with self-hosting will not be provided.** ⚠

### Healthcheck endpoint

The bot exposes an HTTP healthcheck endpoint for deployment platforms (such as Coolify).

- Method/path: `GET /` (configurable via `HEALTHCHECK_PATH`)
- Default bind: `127.0.0.1:8080`
- Config via env vars:
- `HEALTHCHECK_HOST`
- `HEALTHCHECK_PORT`
- `HEALTHCHECK_PATH`

The endpoint returns:
- `200` when DB is responsive, Discord is connected, and no global Discord rate-limit is active
- `503` when any check is failing

# License

This project is licensed under AGPL-3.0. Forks and redistributions must remain open-source. See the LICENSE file for
further info
further info
25 changes: 25 additions & 0 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import discord

from src import log_setup
from src.config import HEALTHCHECK_HOST, HEALTHCHECK_PATH, HEALTHCHECK_PORT
from src.database import init_db, shutdown_db
from src.healthcheck import HealthcheckServer

from .config import TOKEN
Comment thread
ToothyDev marked this conversation as resolved.
Outdated

Expand Down Expand Up @@ -33,21 +35,44 @@ async def on_ready() -> None:

async def start() -> None:
original_exc = None
healthcheck_server = HealthcheckServer(
bot,
host=HEALTHCHECK_HOST,
port=HEALTHCHECK_PORT,
path=HEALTHCHECK_PATH,
)
try:
await init_db()
await healthcheck_server.start()
async with bot:
await bot.start(TOKEN)
except Exception as e: # noqa: BLE001
original_exc = e
finally:
healthcheck_stop_exc = None
try:
await healthcheck_server.stop()
except Exception as healthcheck_exc: # noqa: BLE001
healthcheck_stop_exc = healthcheck_exc

try:
await shutdown_db()
except Exception as e2:
if original_exc:
msg = "Multiple errors happened when starting the bot"

raise ExceptionGroup(msg, [original_exc, e2]) from None
if healthcheck_stop_exc:
msg = "Multiple errors happened during shutdown"

raise ExceptionGroup(msg, [healthcheck_stop_exc, e2]) from None
raise
if healthcheck_stop_exc:
if original_exc:
msg = "Multiple errors happened when starting the bot"

raise ExceptionGroup(msg, [original_exc, healthcheck_stop_exc]) from None
raise healthcheck_stop_exc
if original_exc:
raise original_exc

Expand Down
7 changes: 7 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@

DB_PATH = Path(os.getenv("DB_PATH") or Path("data/database.db")).absolute()
DB_PATH.parent.mkdir(parents=True, exist_ok=True)

HEALTHCHECK_HOST = os.getenv("HEALTHCHECK_HOST", "127.0.0.1")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the default is used for these, they will be empty/unset in the docker container and as such it will fail. THey need to be added as ENV in the dockerfile and can be optionally overridden later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 372b9e5: added Dockerfile ENV HEALTHCHECK_HOST=127.0.0.1 and ENV HEALTHCHECK_PORT=8080 so container defaults are set and can still be overridden.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The defaults should also be removed from there then. And empty hostname could mean the healthcheck is disabled.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot apply this change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in e9781b5: removed app-side defaults from src/config.py for HEALTHCHECK_HOST/HEALTHCHECK_PORT, and made an unset/empty HEALTHCHECK_HOST disable the healthcheck server in the cog. Screenshot: no UI changes (backend/config-only change).

HEALTHCHECK_PORT = int(os.getenv("HEALTHCHECK_PORT", "8080"))
HEALTHCHECK_PATH = os.getenv("HEALTHCHECK_PATH", "/")

if not HEALTHCHECK_PATH.startswith("/"):
Comment thread
ToothyDev marked this conversation as resolved.
Outdated
HEALTHCHECK_PATH = f"/{HEALTHCHECK_PATH}"
139 changes: 139 additions & 0 deletions src/healthcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import asyncio
import json
import logging
from contextlib import suppress
from typing import Any

import discord
from tortoise import Tortoise
from tortoise.exceptions import ConfigurationError, DBConnectionError, OperationalError

logger = logging.getLogger(__name__)

_REQUEST_TIMEOUT_SECONDS = 5


class HealthcheckServer:
def __init__(
self,
bot: discord.Bot,
*,
host: str,
port: int,
path: str = "/",
) -> None:
self.bot = bot
self.host = host
self.port = port
self.path = path
self._server: asyncio.AbstractServer | None = None

async def start(self) -> None:
self._server = await asyncio.start_server(self._handle_connection, host=self.host, port=self.port)
logger.info("Healthcheck server listening on http://%s:%s%s", self.host, self.port, self.path)

async def stop(self) -> None:
if self._server is None:
return

self._server.close()
await self._server.wait_closed()
self._server = None

async def _handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
response_status = 500
response_body: dict[str, Any] = {"status": "error"}

try:
request_line = await asyncio.wait_for(reader.readline(), timeout=_REQUEST_TIMEOUT_SECONDS)
if not request_line:
return

method, raw_target, _ = request_line.decode("utf-8", errors="replace").strip().split(maxsplit=2)
await self._consume_headers(reader)

target = raw_target.split("?", maxsplit=1)[0]
if method != "GET":
response_status = 405
response_body = {"status": "error", "reason": "method_not_allowed"}
elif target != self.path:
response_status = 404
response_body = {"status": "error", "reason": "not_found"}
else:
response_status, response_body = await self._health_response()
except (UnicodeDecodeError, ValueError):
response_status = 400
response_body = {"status": "error", "reason": "bad_request"}
except TimeoutError:
response_status = 408
response_body = {"status": "error", "reason": "request_timeout"}
finally:
writer.write(self._build_response(response_status, response_body))
with suppress(ConnectionError):
await writer.drain()

writer.close()
with suppress(ConnectionError):
await writer.wait_closed()

async def _health_response(self) -> tuple[int, dict[str, Any]]:
db_connected = await self._is_db_connected()
discord_connected = self._is_discord_connected()
discord_unrate_limited = self._is_discord_unrate_limited()

checks = {
"database_connected": db_connected,
"discord_connected": discord_connected,
"discord_no_global_ratelimit": discord_unrate_limited,
}
healthy = all(checks.values())
return (
200 if healthy else 503,
{
"status": "ok" if healthy else "degraded",
"checks": checks,
},
)

async def _is_db_connected(self) -> bool:
try:
connection = Tortoise.get_connection("default")
await connection.execute_query("SELECT 1")
except (ConfigurationError, DBConnectionError, OperationalError):
return False
return True

def _is_discord_connected(self) -> bool:
return self.bot.is_ready() and not self.bot.is_closed()

def _is_discord_unrate_limited(self) -> bool:
global_rate_limit_gate = getattr(self.bot.http, "_global_over", None)
if isinstance(global_rate_limit_gate, asyncio.Event):
return global_rate_limit_gate.is_set()
return not self.bot.is_ws_ratelimited()

async def _consume_headers(self, reader: asyncio.StreamReader) -> None:
while True:
line = await asyncio.wait_for(reader.readline(), timeout=_REQUEST_TIMEOUT_SECONDS)
if not line or line in {b"\r\n", b"\n"}:
return

def _build_response(self, status_code: int, body: dict[str, Any]) -> bytes:
status_text = {
200: "OK",
400: "Bad Request",
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
500: "Internal Server Error",
503: "Service Unavailable",
}.get(status_code, "Internal Server Error")
payload = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
headers = (
f"HTTP/1.1 {status_code} {status_text}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(payload)}\r\n"
"Connection: close\r\n"
"\r\n"
).encode()
return headers + payload
Comment thread
ToothyDev marked this conversation as resolved.
Outdated
Loading