Repository: python-discord/bot

Issue:
Waiting for the website to be up before starting in development mode
# Abstract

When the development mode is enabled, the bot should wait for the website to be up before continuing the startup process.

# Rationale

With the current setup using Docker Compose, the bot will start making requests to the website before the website even starts accepting requests, which will cause the bot to fail to start. 

# Specification

We can have a loop trying to ping the server URL every second for a short duration before moving on. Note that the bot should fail to start after some time. 


Discussion:

Resolving patch:
diff --git a/bot/__main__.py b/bot/__main__.py
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,10 +1,28 @@
+import logging
+
+import aiohttp
+
 import bot
 from bot import constants
-from bot.bot import Bot
+from bot.bot import Bot, StartupError
 from bot.log import setup_sentry
 
 setup_sentry()
 
-bot.instance = Bot.create()
-bot.instance.load_extensions()
-bot.instance.run(constants.Bot.token)
+try:
+    bot.instance = Bot.create()
+    bot.instance.load_extensions()
+    bot.instance.run(constants.Bot.token)
+except StartupError as e:
+    message = "Unknown Startup Error Occurred."
+    if isinstance(e.exception, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError)):
+        message = "Could not connect to site API. Is it running?"
+    elif isinstance(e.exception, OSError):
+        message = "Could not connect to Redis. Is it running?"
+
+    # The exception is logged with an empty message so the actual message is visible at the bottom
+    log = logging.getLogger("bot")
+    log.fatal("", exc_info=e.exception)
+    log.fatal(message)
+
+    exit(69)
diff --git a/bot/bot.py b/bot/bot.py
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -19,6 +19,14 @@
 LOCALHOST = "127.0.0.1"
 
 
+class StartupError(Exception):
+    """Exception class for startup errors."""
+
+    def __init__(self, base: Exception):
+        super().__init__()
+        self.exception = base
+
+
 class Bot(commands.Bot):
     """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client."""
 
@@ -81,6 +89,22 @@ async def cache_filter_list_data(self) -> None:
         for item in full_cache:
             self.insert_item_into_filter_list_cache(item)
 
+    async def ping_services(self) -> None:
+        """A helper to make sure all the services the bot relies on are available on startup."""
+        # Connect Site/API
+        attempts = 0
+        while True:
+            try:
+                log.info(f"Attempting site connection: {attempts + 1}/{constants.URLs.connect_max_retries}")
+                await self.api_client.get("healthcheck")
+                break
+
+            except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError):
+                attempts += 1
+                if attempts == constants.URLs.connect_max_retries:
+                    raise
+                await asyncio.sleep(constants.URLs.connect_cooldown)
+
     @classmethod
     def create(cls) -> "Bot":
         """Create and return an instance of a Bot."""
@@ -223,6 +247,11 @@ async def login(self, *args, **kwargs) -> None:
             # here. Normally, this shouldn't happen.
             await self.redis_session.connect()
 
+        try:
+            await self.ping_services()
+        except Exception as e:
+            raise StartupError(e)
+
         # Build the FilterList cache
         await self.cache_filter_list_data()
 
@@ -318,5 +347,8 @@ def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession:
         use_fakeredis=constants.Redis.use_fakeredis,
         global_namespace="bot",
     )
-    loop.run_until_complete(redis_session.connect())
+    try:
+        loop.run_until_complete(redis_session.connect())
+    except OSError as e:
+        raise StartupError(e)
     return redis_session
diff --git a/bot/constants.py b/bot/constants.py
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -531,6 +531,8 @@ class URLs(metaclass=YAMLGetter):
     github_bot_repo: str
 
     # Base site vars
+    connect_max_retries: int
+    connect_cooldown: int
     site: str
     site_api: str
     site_schema: str


Test patch: