diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index 4d9a6b453..069a1fde1 100644 --- 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 index e8b8ba914..080e9325d 100644 --- 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 index 0ccf5f286..cdfd6bb42 100644 --- 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