Large list of channels cause Quit due to "excess flood"
When joining a large number of channels (e.g. 40) in freenode, the bot is disconnected because of excess flood. This happens even when setting the 'throttle_join=1'.

It seems that the 'throttle_join' command does not help because it just limits the JOIN commands. After all channels are joined, sopel sends TOPIC and WHO commands for each channel joined which seem to cause the excess flood.

A workaround is to use a custom module that joins each channel after a few seconds. In this way, the TOPIC and WHO commands are called right after joining the channel instead of being sent in batch at the end.

diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
index 468539fe..ec059183 100644
--- a/docs/source/configuration.rst
+++ b/docs/source/configuration.rst
@@ -186,8 +186,20 @@ join is configured by :attr:`~CoreSection.channels`::
         "#sopelunkers"
 
 It is possible to slow down the initial joining of channels using
-:attr:`~CoreSection.throttle_join`, for example if the IRC network kicks
-clients that join too many channels too quickly.
+:attr:`~CoreSection.throttle_join` and :attr:`~CoreSection.throttle_wait`, for
+example if the IRC network kicks clients that join too many channels too
+quickly::
+
+    [core]
+    channels =
+        "#sopel"
+        "#sopelunkers"
+        # ... too many channels ...
+        "#justonemore"
+    throttle_join = 4
+    throttle_wait = 2
+
+In that example, Sopel will send ``JOIN`` and ``WHO`` commands 4 by 4 every 2s.
 
 Flood Prevention
 ----------------
diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py
index f960484a..af900562 100644
--- a/sopel/config/core_section.py
+++ b/sopel/config/core_section.py
@@ -416,11 +416,33 @@ class CoreSection(StaticSection):
     .. versionadded:: 7.0
     """
 
-    throttle_join = ValidatedAttribute('throttle_join', int)
+    throttle_join = ValidatedAttribute('throttle_join', int, default=0)
     """Slow down the initial join of channels to prevent getting kicked.
 
     Sopel will only join this many channels at a time, sleeping for a second
     between each batch. This is unnecessary on most networks.
+
+    If not set, or set to 0, Sopel won't slow down the initial join.
+
+    .. seealso::
+
+       :attr:`throttle_wait` controls the time Sopel waits between batches
+       of channels.
+
+    """
+
+    throttle_wait = ValidatedAttribute('throttle_wait', int, default=1)
+    """Time in seconds Sopel waits at the initial join of channels.
+
+    By default, it waits 1s between batches.
+
+    For example, with ``throttle_join = 2`` and ``throttle_wait = 5`` it will
+    wait 5s every 2 channels it joins.
+
+    .. seealso::
+
+        :attr:`throttle_join` controls channel batch size.
+
     """
 
     timeout = ValidatedAttribute('timeout', int, default=120)
diff --git a/sopel/coretasks.py b/sopel/coretasks.py
index d6eef445..81feacc0 100644
--- a/sopel/coretasks.py
+++ b/sopel/coretasks.py
@@ -9,22 +9,23 @@ dispatch function in bot.py and making it easier to maintain.
 # (yanovich.net)
 # Copyright © 2012, Elad Alfassa <elad@fedoraproject.org>
 # Copyright 2012-2015, Elsie Powell embolalia.com
+# Copyright 2019, Florian Strzelecki <florian.strzelecki@gmail.com>
+#
 # Licensed under the Eiffel Forum License 2.
 from __future__ import unicode_literals, absolute_import, print_function, division
 
-import logging
-from random import randint
+import base64
+import collections
 import datetime
+import logging
 import re
 import sys
 import time
-import sopel
-import sopel.module
-import sopel.tools.web
+from random import randint
+
+from sopel import loader, module
 from sopel.irc.utils import CapReq
-from sopel.tools import Identifier, iteritems, events
-from sopel.tools.target import User, Channel
-import base64
+from sopel.tools import Identifier, events, iteritems, jobs, target, web
 
 if sys.version_info.major >= 3:
     unicode = str
@@ -35,6 +36,46 @@ batched_caps = {}
 who_reqs = {}  # Keeps track of reqs coming from this module, rather than others
 
 
+def setup(bot):
+    bot.memory['join_events_queue'] = collections.deque()
+
+    # Manage JOIN flood protection
+    if bot.settings.core.throttle_join:
+        wait_interval = max(bot.settings.core.throttle_wait, 1)
+
+        @module.interval(wait_interval)
+        def processing_job(bot):
+            _join_event_processing(bot)
+
+        loader.clean_callable(processing_job, bot.settings)
+        bot.scheduler.add_job(jobs.Job(wait_interval, processing_job))
+
+
+def shutdown(bot):
+    try:
+        bot.memory['join_events_queue'].clear()
+    except KeyError:
+        pass
+
+
+def _join_event_processing(bot):
+    """Process a batch of JOIN event from the ``join_events_queue`` queue.
+
+    Every time this function is executed, it processes at most
+    ``throttle_join`` JOIN events. For each JOIN, it sends a WHO request to
+    know more about the channel. This will prevent an excess of flood when
+    there are too many channels to join at once.
+    """
+    batch_size = max(bot.settings.core.throttle_join, 1)
+    for _ in range(batch_size):
+        try:
+            channel = bot.memory['join_events_queue'].popleft()
+        except IndexError:
+            break
+        LOGGER.debug('Sending WHO after channel JOIN: %s', channel)
+        _send_who(bot, channel)
+
+
 def auth_after_register(bot):
     """Do NickServ/AuthServ auth"""
     if bot.config.core.auth_method:
@@ -75,17 +116,18 @@ def _execute_perform(bot):
         bot.write((command,))
 
 
-@sopel.module.require_privmsg("This command only works as a private message.")
-@sopel.module.require_admin("This command requires admin privileges.")
-@sopel.module.commands('execute')
+@module.require_privmsg("This command only works as a private message.")
+@module.require_admin("This command requires admin privileges.")
+@module.commands('execute')
 def execute_perform(bot, trigger):
     """Execute commands specified to perform on IRC server connect."""
     _execute_perform(bot)
 
 
-@sopel.module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT)
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.priority('high')
+@module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT)
+@module.thread(False)
+@module.unblockable
 def startup(bot, trigger):
     """Do tasks related to connecting to the network.
 
@@ -109,15 +151,33 @@ def startup(bot, trigger):
 
     bot.memory['retry_join'] = dict()
 
-    if bot.config.core.throttle_join:
+    channels = bot.config.core.channels
+    if not channels:
+        LOGGER.info('No initial channels to JOIN.')
+    elif bot.config.core.throttle_join:
         throttle_rate = int(bot.config.core.throttle_join)
+        throttle_wait = max(bot.config.core.throttle_wait, 1)
         channels_joined = 0
-        for channel in bot.config.core.channels:
+
+        LOGGER.info(
+            'Joining %d channels (with JOIN throttle ON); '
+            'this may take a moment.',
+            len(channels))
+
+        for channel in channels:
             channels_joined += 1
             if not channels_joined % throttle_rate:
-                time.sleep(1)
+                LOGGER.debug(
+                    'Waiting %ds before next JOIN batch.',
+                    throttle_wait)
+                time.sleep(throttle_wait)
             bot.join(channel)
     else:
+        LOGGER.info(
+            'Joining %d channels (with JOIN throttle OFF); '
+            'this may take a moment.',
+            len(channels))
+
         for channel in bot.config.core.channels:
             bot.join(channel)
 
@@ -135,9 +195,9 @@ def startup(bot, trigger):
     _execute_perform(bot)
 
 
-@sopel.module.require_privmsg()
-@sopel.module.require_owner()
-@sopel.module.commands('useserviceauth')
+@module.require_privmsg()
+@module.require_owner()
+@module.commands('useserviceauth')
 def enable_service_auth(bot, trigger):
     if bot.config.core.owner_account:
         return
@@ -155,14 +215,13 @@ def enable_service_auth(bot, trigger):
             'owner.')
 
 
-@sopel.module.event(events.ERR_NOCHANMODES)
-@sopel.module.priority('high')
+@module.event(events.ERR_NOCHANMODES)
+@module.priority('high')
 def retry_join(bot, trigger):
-    """Give NickServer enough time to identify on a +R channel.
+    """Give NickServ enough time to identify on a +R channel.
 
     Give NickServ enough time to identify, and retry rejoining an
     identified-only (+R) channel. Maximum of ten rejoin attempts.
-
     """
     channel = trigger.args[1]
     if channel in bot.memory['retry_join'].keys():
@@ -179,11 +238,11 @@ def retry_join(bot, trigger):
     bot.join(channel)
 
 
-@sopel.module.rule('(.*)')
-@sopel.module.event(events.RPL_NAMREPLY)
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.rule('(.*)')
+@module.event(events.RPL_NAMREPLY)
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def handle_names(bot, trigger):
     """Handle NAMES response, happens when joining to channels."""
     names = trigger.split()
@@ -196,18 +255,18 @@ def handle_names(bot, trigger):
     if channel not in bot.privileges:
         bot.privileges[channel] = dict()
     if channel not in bot.channels:
-        bot.channels[channel] = Channel(channel)
+        bot.channels[channel] = target.Channel(channel)
 
     # This could probably be made flexible in the future, but I don't think
     # it'd be worth it.
     # If this ever needs to be updated, remember to change the mode handling in
     # the WHO-handler functions below, too.
     mapping = {
-        "+": sopel.module.VOICE,
-        "%": sopel.module.HALFOP,
-        "@": sopel.module.OP,
-        "&": sopel.module.ADMIN,
-        "~": sopel.module.OWNER,
+        "+": module.VOICE,
+        "%": module.HALFOP,
+        "@": module.OP,
+        "&": module.ADMIN,
+        "~": module.OWNER,
     }
 
     for name in names:
@@ -223,16 +282,16 @@ def handle_names(bot, trigger):
             # in a NAMES reply, unfortunately.
             # Fortunately, the user should already exist in bot.users by the
             # time this code runs, so this is 99.9% ass-covering.
-            user = User(nick, None, None)
+            user = target.User(nick, None, None)
             bot.users[nick] = user
         bot.channels[channel].add_user(user, privs=priv)
 
 
-@sopel.module.rule('(.*)')
-@sopel.module.event('MODE')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.rule('(.*)')
+@module.event('MODE')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_modes(bot, trigger):
     """Track usermode changes and keep our lists of ops up to date."""
     # Mode message format: <channel> *( ( "-" / "+" ) *<modes> *<modeparams> )
@@ -265,11 +324,11 @@ def track_modes(bot, trigger):
     nicks = [Identifier(nick) for nick in trigger.args[2:]]
 
     mapping = {
-        "v": sopel.module.VOICE,
-        "h": sopel.module.HALFOP,
-        "o": sopel.module.OP,
-        "a": sopel.module.ADMIN,
-        "q": sopel.module.OWNER,
+        "v": module.VOICE,
+        "h": module.HALFOP,
+        "o": module.OP,
+        "a": module.ADMIN,
+        "q": module.OWNER,
     }
 
     # Parse modes before doing anything else
@@ -290,6 +349,7 @@ def track_modes(bot, trigger):
         # Something fucky happening, like unusual batching of non-privilege
         # modes together with the ones we expect. Way easier to just re-WHO
         # than try to account for non-standard parameter-taking modes.
+        LOGGER.debug('Sending WHO for channel: %s', channel)
         _send_who(bot, channel)
         return
 
@@ -316,10 +376,10 @@ def track_modes(bot, trigger):
             bot.channels[channel].privileges[nick] = priv
 
 
-@sopel.module.event('NICK')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('NICK')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_nicks(bot, trigger):
     """Track nickname changes and maintain our chanops list accordingly."""
     old = trigger.nick
@@ -354,21 +414,21 @@ def track_nicks(bot, trigger):
         bot.users[new] = bot.users.pop(old)
 
 
-@sopel.module.rule('(.*)')
-@sopel.module.event('PART')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.rule('(.*)')
+@module.event('PART')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_part(bot, trigger):
     nick = trigger.nick
     channel = trigger.sender
     _remove_from_channel(bot, nick, channel)
 
 
-@sopel.module.event('KICK')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('KICK')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_kick(bot, trigger):
     nick = Identifier(trigger.args[1])
     channel = trigger.sender
@@ -423,7 +483,7 @@ def _send_who(bot, channel):
     bot.channels[Identifier(channel)].last_who = datetime.datetime.utcnow()
 
 
-@sopel.module.interval(30)
+@module.interval(30)
 def _periodic_send_who(bot):
     """Periodically send a WHO request to keep user information up-to-date."""
     if 'away-notify' in bot.enabled_capabilities:
@@ -447,26 +507,40 @@ def _periodic_send_who(bot):
 
     if selected_channel is not None:
         # selected_channel's last who is either none or the oldest valid
+        LOGGER.debug('Sending WHO for channel: %s', selected_channel)
         _send_who(bot, selected_channel)
 
 
-@sopel.module.event('JOIN')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('JOIN')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_join(bot, trigger):
-    if trigger.nick == bot.nick and trigger.sender not in bot.channels:
-        bot.privileges[trigger.sender] = dict()
-        bot.channels[trigger.sender] = Channel(trigger.sender)
-        _send_who(bot, trigger.sender)
+    channel = trigger.sender
+
+    # is it a new channel?
+    if channel not in bot.channels:
+        LOGGER.info('Channel joined: %s', channel)
+        bot.privileges[channel] = dict()
+        bot.channels[channel] = target.Channel(channel)
+
+    # did *we* just join?
+    if trigger.nick == bot.nick:
+        if bot.settings.core.throttle_join:
+            LOGGER.debug('JOIN event added to queue for channel: %s', channel)
+            bot.memory['join_events_queue'].append(channel)
+        else:
+            LOGGER.debug('Send direct WHO for channel: %s', channel)
+            _send_who(bot, channel)
 
-    bot.privileges[trigger.sender][trigger.nick] = 0
+    # set initial values
+    bot.privileges[channel][trigger.nick] = 0
 
     user = bot.users.get(trigger.nick)
     if user is None:
-        user = User(trigger.nick, trigger.user, trigger.host)
+        user = target.User(trigger.nick, trigger.user, trigger.host)
         bot.users[trigger.nick] = user
-    bot.channels[trigger.sender].add_user(user)
+    bot.channels[channel].add_user(user)
 
     if len(trigger.args) > 1 and trigger.args[1] != '*' and (
             'account-notify' in bot.enabled_capabilities and
@@ -474,10 +548,10 @@ def track_join(bot, trigger):
         user.account = trigger.args[1]
 
 
-@sopel.module.event('QUIT')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('QUIT')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_quit(bot, trigger):
     for chanprivs in bot.privileges.values():
         chanprivs.pop(trigger.nick, None)
@@ -486,10 +560,10 @@ def track_quit(bot, trigger):
     bot.users.pop(trigger.nick, None)
 
 
-@sopel.module.event('CAP')
-@sopel.module.thread(False)
-@sopel.module.priority('high')
-@sopel.module.unblockable
+@module.event('CAP')
+@module.thread(False)
+@module.priority('high')
+@module.unblockable
 def receive_cap_list(bot, trigger):
     cap = trigger.strip('-=~')
     # Server is listing capabilities
@@ -666,7 +740,7 @@ def send_authenticate(bot, token):
         bot.write(('AUTHENTICATE', '+'))
 
 
-@sopel.module.event('AUTHENTICATE')
+@module.event('AUTHENTICATE')
 def auth_proceed(bot, trigger):
     if trigger.args[0] != '+':
         # How did we get here? I am not good with computer.
@@ -685,7 +759,7 @@ def auth_proceed(bot, trigger):
     send_authenticate(bot, sasl_token)
 
 
-@sopel.module.event(events.RPL_SASLSUCCESS)
+@module.event(events.RPL_SASLSUCCESS)
 def sasl_success(bot, trigger):
     bot.write(('CAP', 'END'))
 
@@ -693,11 +767,11 @@ def sasl_success(bot, trigger):
 # Live blocklist editing
 
 
-@sopel.module.commands('blocks')
-@sopel.module.priority('low')
-@sopel.module.thread(False)
-@sopel.module.unblockable
-@sopel.module.require_admin
+@module.commands('blocks')
+@module.priority('low')
+@module.thread(False)
+@module.unblockable
+@module.require_admin
 def blocks(bot, trigger):
     """
     Manage Sopel's blocking features.\
@@ -776,19 +850,20 @@ def blocks(bot, trigger):
         bot.reply(STRINGS['huh'])
 
 
-@sopel.module.event('ACCOUNT')
+@module.event('ACCOUNT')
 def account_notify(bot, trigger):
     if trigger.nick not in bot.users:
-        bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host)
+        bot.users[trigger.nick] = target.User(
+            trigger.nick, trigger.user, trigger.host)
     account = trigger.args[0]
     if account == '*':
         account = None
     bot.users[trigger.nick].account = account
 
 
-@sopel.module.event(events.RPL_WHOSPCRPL)
-@sopel.module.priority('high')
-@sopel.module.unblockable
+@module.event(events.RPL_WHOSPCRPL)
+@module.priority('high')
+@module.unblockable
 def recv_whox(bot, trigger):
     if len(trigger.args) < 2 or trigger.args[1] not in who_reqs:
         # Ignored, some module probably called WHO
@@ -805,7 +880,7 @@ def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=N
     nick = Identifier(nick)
     channel = Identifier(channel)
     if nick not in bot.users:
-        usr = User(nick, user, host)
+        usr = target.User(nick, user, host)
         bot.users[nick] = usr
     else:
         usr = bot.users[nick]
@@ -823,25 +898,25 @@ def _record_who(bot, channel, user, host, nick, account=None, away=None, modes=N
     priv = 0
     if modes:
         mapping = {
-            "+": sopel.module.VOICE,
-            "%": sopel.module.HALFOP,
-            "@": sopel.module.OP,
-            "&": sopel.module.ADMIN,
-            "~": sopel.module.OWNER,
+            "+": module.VOICE,
+            "%": module.HALFOP,
+            "@": module.OP,
+            "&": module.ADMIN,
+            "~": module.OWNER,
         }
         for c in modes:
             priv = priv | mapping[c]
     if channel not in bot.channels:
-        bot.channels[channel] = Channel(channel)
+        bot.channels[channel] = target.Channel(channel)
     bot.channels[channel].add_user(usr, privs=priv)
     if channel not in bot.privileges:
         bot.privileges[channel] = dict()
     bot.privileges[channel][nick] = priv
 
 
-@sopel.module.event(events.RPL_WHOREPLY)
-@sopel.module.priority('high')
-@sopel.module.unblockable
+@module.event(events.RPL_WHOREPLY)
+@module.priority('high')
+@module.unblockable
 def recv_who(bot, trigger):
     channel, user, host, _, nick, status = trigger.args[1:7]
     away = 'G' in status
@@ -849,30 +924,31 @@ def recv_who(bot, trigger):
     _record_who(bot, channel, user, host, nick, away=away, modes=modes)
 
 
-@sopel.module.event(events.RPL_ENDOFWHO)
-@sopel.module.priority('high')
-@sopel.module.unblockable
+@module.event(events.RPL_ENDOFWHO)
+@module.priority('high')
+@module.unblockable
 def end_who(bot, trigger):
     if _whox_enabled(bot):
         who_reqs.pop(trigger.args[1], None)
 
 
-@sopel.module.event('AWAY')
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('AWAY')
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_notify(bot, trigger):
     if trigger.nick not in bot.users:
-        bot.users[trigger.nick] = User(trigger.nick, trigger.user, trigger.host)
+        bot.users[trigger.nick] = target.User(
+            trigger.nick, trigger.user, trigger.host)
     user = bot.users[trigger.nick]
     user.away = bool(trigger.args)
 
 
-@sopel.module.event('TOPIC')
-@sopel.module.event(events.RPL_TOPIC)
-@sopel.module.priority('high')
-@sopel.module.thread(False)
-@sopel.module.unblockable
+@module.event('TOPIC')
+@module.event(events.RPL_TOPIC)
+@module.priority('high')
+@module.thread(False)
+@module.unblockable
 def track_topic(bot, trigger):
     if trigger.event != 'TOPIC':
         channel = trigger.args[1]
@@ -883,8 +959,8 @@ def track_topic(bot, trigger):
     bot.channels[channel].topic = trigger.args[-1]
 
 
-@sopel.module.rule(r'(?u).*(.+://\S+).*')
-@sopel.module.unblockable
+@module.rule(r'(?u).*(.+://\S+).*')
+@module.unblockable
 def handle_url_callbacks(bot, trigger):
     """Dispatch callbacks on URLs
 
@@ -893,7 +969,7 @@ def handle_url_callbacks(bot, trigger):
     """
     schemes = bot.config.core.auto_url_schemes
     # find URLs in the trigger
-    for url in sopel.tools.web.search_urls(trigger, schemes=schemes):
+    for url in web.search_urls(trigger, schemes=schemes):
         # find callbacks for said URL
         for function, match in bot.search_url_callbacks(url):
             # trigger callback defined by the `@url` decorator
['docs/source/configuration.rst', 'sopel/coretasks.py', 'sopel/config/core_section.py']