Repository: sopel-irc/sopel

Issue:
Common connection errors result in tracebacks
Inexperienced users simply don't have the patience and/or knowledge to dig through Sopel's output and find the cause of a connection error (e.g. #2110). They may blame Sopel for things that are outside of its control, maybe even _their own_ fault.

It would be useful if Sopel could catch _a limited set_ of connection problems and nudge users in the right direction. Stuff like:

* Invalid/expired SSL certificate
* Invalid address
* Hostname not found
* Connection timeout (I think this is already handled)

Of course the actual exception and traceback should still make its way to the exception log, but for purposes of user-friendliness—we claim to be a "simple" and "easy-to-use" bot, after all—terminating execution with _a clear error description_ instead of a traceback pointing through Sopel's internals into Python stdlib code would be a good step forward.


Discussion:

Resolving patch:
diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py
--- a/sopel/irc/__init__.py
+++ b/sopel/irc/__init__.py
@@ -448,13 +448,19 @@ def on_message_sent(self, raw: str) -> None:
             )
             self.dispatch(pretrigger)
 
+    @deprecated(
+        'This method was used to log errors with asynchat; '
+        'use logging.getLogger("sopel.exception") instead.',
+        version='8.0',
+        removed_in='9.0',
+    )
     def on_error(self) -> None:
         """Handle any uncaptured error in the bot itself."""
         LOGGER.error('Fatal error in core, please review exceptions log.')
 
         err_log = logging.getLogger('sopel.exceptions')
         err_log.error(
-            'Fatal error in core, handle_error() was called.\n'
+            'Fatal error in core, bot.on_error() was called.\n'
             'Last Line:\n%s',
             self.last_raw_line,
         )
diff --git a/sopel/irc/abstract_backends.py b/sopel/irc/abstract_backends.py
--- a/sopel/irc/abstract_backends.py
+++ b/sopel/irc/abstract_backends.py
@@ -15,6 +15,7 @@
 from __future__ import annotations
 
 import abc
+import logging
 from typing import Optional, TYPE_CHECKING
 
 from .utils import safe
@@ -41,6 +42,17 @@ def __init__(self, bot: AbstractBot):
     def is_connected(self) -> bool:
         """Tell if the backend is connected or not."""
 
+    def log_exception(self) -> None:
+        """Log an exception to ``sopel.exceptions``.
+
+        The IRC backend must use this method to log any exception that isn't
+        caught by the bot itself (i.e. while handling messages), such as
+        connection errors, SSL errors, etc.
+        """
+        err_log = logging.getLogger('sopel.exceptions')
+        err_log.exception('Exception in core')
+        err_log.error('----------------------------------------')
+
     @abc.abstractmethod
     def on_irc_error(self, pretrigger: PreTrigger) -> None:
         """Action to perform when the server sends an error event.
diff --git a/sopel/irc/backends.py b/sopel/irc/backends.py
--- a/sopel/irc/backends.py
+++ b/sopel/irc/backends.py
@@ -17,6 +17,7 @@
 import asyncio
 import logging
 import signal
+import socket
 import ssl
 import threading
 from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
@@ -56,7 +57,7 @@ def __init__(
     ):
         super().__init__(bot)
 
-    def is_connected(self) -> False:
+    def is_connected(self) -> bool:
         """Check if the backend is connected to an IRC server.
 
         **Always returns False:** This backend type is never connected.
@@ -377,40 +378,124 @@ def get_connection_kwargs(self) -> Dict:
             'local_addr': self._source_address,
         }
 
-    async def _run_forever(self) -> None:
-        self._loop = asyncio.get_running_loop()
-
-        # register signal handlers
-        for quit_signal in QUIT_SIGNALS:
-            self._loop.add_signal_handler(quit_signal, self._signal_quit)
-        for restart_signal in RESTART_SIGNALS:
-            self._loop.add_signal_handler(restart_signal, self._signal_restart)
+    async def _connect_to_server(
+        self, **connection_kwargs
+    ) -> Tuple[
+        Optional[asyncio.StreamReader],
+        Optional[asyncio.StreamWriter],
+    ]:
+        reader: Optional[asyncio.StreamReader] = None
+        writer: Optional[asyncio.StreamWriter] = None
 
         # open connection
         try:
-            self._reader, self._writer = await asyncio.open_connection(
-                **self.get_connection_kwargs(),
+            reader, writer = await asyncio.open_connection(
+                **connection_kwargs,
+            )
+
+        # SSL Errors (certificate verification and generic SSL errors)
+        except ssl.SSLCertVerificationError as err:
+            LOGGER.error(
+                'Unable to connect due to '
+                'SSL certificate verification failure: %s',
+                err,
             )
-        except ssl.SSLError:
-            LOGGER.exception('Unable to connect due to SSL error.')
+            self.log_exception()
             # tell the bot to quit without restart
             self.bot.hasquit = True
             self.bot.wantsrestart = False
-            return
-        except Exception:
-            LOGGER.exception('Unable to connect.')
+        except ssl.SSLError as err:
+            LOGGER.error('Unable to connect due to an SSL error: %s', err)
+            self.log_exception()
+            # tell the bot to quit without restart
+            self.bot.hasquit = True
+            self.bot.wantsrestart = False
+
+        # Specific connection error (invalid address and timeout)
+        except socket.gaierror as err:
+            LOGGER.error(
+                'Unable to connect due to invalid IRC server address: %s',
+                err,
+            )
+            LOGGER.error(
+                'You should verify that "%s:%s" is the correct address '
+                'to connect to the IRC server.',
+                connection_kwargs.get('host'),
+                connection_kwargs.get('port'),
+            )
+            self.log_exception()
+            # tell the bot to quit without restart
+            self.bot.hasquit = True
+            self.bot.wantsrestart = False
+        except TimeoutError as err:
+            LOGGER.error('Unable to connect due to a timeout: %s', err)
+            self.log_exception()
+            # tell the bot to quit without restart
+            self.bot.hasquit = True
+            self.bot.wantsrestart = False
+
+        # Generic connection error
+        except ConnectionError as err:
+            LOGGER.error('Unable to connect: %s', err)
+            self.log_exception()
+            # tell the bot to quit without restart
+            self.bot.hasquit = True
+            self.bot.wantsrestart = False
+
+        # Generic OSError (used for any unspecific connection error)
+        except OSError as err:
+            LOGGER.error('Unable to connect: %s', err)
+            LOGGER.error(
+                'You should verify that "%s:%s" is the correct address '
+                'to connect to the IRC server.',
+                connection_kwargs.get('host'),
+                connection_kwargs.get('port'),
+            )
+            self.log_exception()
+            # tell the bot to quit without restart
+            self.bot.hasquit = True
+            self.bot.wantsrestart = False
+
+        # Unexpected error
+        except Exception as err:
+            LOGGER.error(
+                'Unable to connect due to an unexpected error: %s',
+                err,
+            )
+            self.log_exception()
             # until there is a way to prevent an infinite loop of connection
             # error and reconnect, we have to tell the bot to quit here
             # TODO: prevent infinite connection failure loop
             self.bot.hasquit = True
             self.bot.wantsrestart = False
-            return
 
-        self._connected = True
+        return reader, writer
 
+    async def _run_forever(self) -> None:
+        self._loop = asyncio.get_running_loop()
+        connection_kwargs = self.get_connection_kwargs()
+
+        # register signal handlers
+        for quit_signal in QUIT_SIGNALS:
+            self._loop.add_signal_handler(quit_signal, self._signal_quit)
+        for restart_signal in RESTART_SIGNALS:
+            self._loop.add_signal_handler(restart_signal, self._signal_restart)
+
+        # connect to socket
+        LOGGER.debug('Attempt connection.')
+        self._reader, self._writer = await self._connect_to_server(
+            **connection_kwargs
+        )
+        if not self._reader or not self._writer:
+            LOGGER.debug('Connection attempt failed.')
+            return
+
+        # on socket connection
         LOGGER.debug('Connection registered.')
+        self._connected = True
         self.bot.on_connect()
 
+        # read forever
         LOGGER.debug('Waiting for messages...')
         self._read_task = asyncio.create_task(self.read_forever())
         try:
@@ -420,6 +505,7 @@ async def _run_forever(self) -> None:
         else:
             LOGGER.debug('Reader received EOF.')
 
+        # on socket disconnection
         self._connected = False
 
         # cancel timeout tasks


Test patch: