Repository: hikari-py/hikari
Pull request #347 (2020-11-10 21:44:18)

Implement a lock to help ratelimiting handling
### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.


Patch:
diff --git a/hikari/impl/bot.py b/hikari/impl/bot.py
index 0876ab62..df5230a1 100644
--- a/hikari/impl/bot.py
+++ b/hikari/impl/bot.py
@@ -219,26 +219,21 @@ class BotApp(traits.BotAware, event_dispatcher.EventDispatcher):
     ```py
     import os
 
-    from logging.config import dictConfig
-    from hikari import Bot, Intents
-
-    bot = Bot(token=os.environ["BOT_TOKEN"], intents=Intents.ALL, logs="INFO")
+    import hikari
 
     # We want to make gateway logs output as DEBUG, and TRACE for all ratelimit content.
-    dictConfig({
-        "version": 1,
-        "incremental": True,
-        "loggers": {
-            "hikari.gateway": {
-                "level": "DEBUG"
-            },
-            "hikari.ratelimits": {
-                "level": "TRACE_HIKARI"
+    bot = hikari.Bot(
+        token=os.environ["BOT_TOKEN"],
+        logs={
+            "version": 1,
+            "incremental": True,
+            "loggers": {
+                "hikari.gateway": {"level": "DEBUG"},
+                "hikari.ratelimits": {"level": "TRACE_HIKARI"},
             },
         },
-    })
+    )
     ```
-
     """
 
     __slots__: typing.Sequence[str] = (
@@ -659,7 +654,7 @@ def handle_os_interrupt(signum: int, frame: types.FrameType) -> None:
             # Signals on POSIX only occur on the main thread usually, too, so we need to ensure this is
             # threadsafe if we want the user's application to still shut down if on a separate thread.
             # We log native thread IDs purely for debugging purposes.
-            if _LOGGER.getEffectiveLevel() <= ux.TRACE:
+            if _LOGGER.isEnabledFor(ux.TRACE):
                 _LOGGER.log(
                     ux.TRACE,
                     "interrupt %s occurred on thread %s, bot on thread %s will be notified to shut down shortly\n"
@@ -732,7 +727,7 @@ async def start(
         idle_since: typing.Optional[datetime.datetime] = None,
         ignore_session_start_limit: bool = False,
         large_threshold: int = 250,
-        shard_ids: typing.Optional[typing.Set[int]] = None,
+        shard_ids: typing.Optional[typing.AbstractSet[int]] = None,
         shard_count: typing.Optional[int] = None,
         status: presences.Status = presences.Status.ONLINE,
     ) -> None:
diff --git a/hikari/impl/buckets.py b/hikari/impl/buckets.py
index 8a017776..5eb16429 100644
--- a/hikari/impl/buckets.py
+++ b/hikari/impl/buckets.py
@@ -228,7 +228,7 @@
 class RESTBucket(rate_limits.WindowedBurstRateLimiter):
     """Represents a rate limit for an HTTP endpoint.
 
-    Component to represent an active rate limit bucket on a specific HTTP _route
+    Component to represent an active rate limit bucket on a specific HTTP route
     with a specific major parameter combo.
 
     This is somewhat similar to the `WindowedBurstRateLimiter` in how it
@@ -250,7 +250,7 @@ class RESTBucket(rate_limits.WindowedBurstRateLimiter):
     __slots__: typing.Sequence[str] = ("compiled_route",)
 
     compiled_route: typing.Final[routes.CompiledRoute]
-    """The compiled _route that this rate limit is covering."""
+    """The compiled route that this rate limit is covering."""
 
     def __init__(self, name: str, compiled_route: routes.CompiledRoute) -> None:
         super().__init__(name, 1, 1)
@@ -261,31 +261,18 @@ def is_unknown(self) -> bool:
         """Return `builtins.True` if the bucket represents an `UNKNOWN` bucket."""
         return self.name.startswith(UNKNOWN_HASH)
 
-    def acquire(self, max_rate_limit: float = float("inf")) -> asyncio.Future[None]:
+    def acquire(self) -> asyncio.Future[None]:
         """Acquire time on this rate limiter.
 
         !!! note
             You should afterwards invoke `RESTBucket.update_rate_limit` to
             update any rate limit information you are made aware of.
 
-        Parameters
-        ----------
-        max_rate_limit : builtins.float
-            The max number of seconds to backoff for when rate limited. Anything
-            greater than this will instead raise an error.
-
-            The default is an infinite value, which will thus never time out.
-
         Returns
         -------
         asyncio.Future[builtins.None]
             A future that should be awaited immediately. Once the future completes,
             you are allowed to proceed with your operation.
-
-
-            If the reset-after time for the bucket is greater than
-            `max_rate_limit`, then this will contain `RateLimitTooLongError`
-            as an exception.
         """
         return aio.completed_future(None) if self.is_unknown else super().acquire()
 
@@ -353,7 +340,7 @@ class RESTBucketManager:
 
     real_hashes_to_buckets: typing.Final[typing.MutableMapping[str, RESTBucket]]
     """Maps full bucket hashes (`X-RateLimit-Bucket` appended with a hash of
-    major parameters used in that compiled _route) to their corresponding rate
+    major parameters used in that compiled route) to their corresponding rate
     limiters.
     """
 
@@ -428,7 +415,7 @@ async def gc(self, poll_period: float, expire_after: float) -> None:  # noqa: D4
         """The garbage collector loop.
 
         This is designed to run in the background and manage removing unused
-        _route references from the rate-limiter collection to save memory.
+        route references from the rate-limiter collection to save memory.
 
         This will run forever until `RESTBucketManager.closed_event` is set.
         This will invoke `RESTBucketManager.do_gc_pass` periodically.
@@ -516,12 +503,12 @@ def do_gc_pass(self, expire_after: float) -> None:
         _LOGGER.log(ux.TRACE, "purged %s stale buckets, %s remain in survival, %s active", dead, survival, active)
 
     def acquire(self, compiled_route: routes.CompiledRoute) -> asyncio.Future[None]:
-        """Acquire a bucket for the given _route.
+        """Acquire a bucket for the given route.
 
         Parameters
         ----------
         compiled_route : hikari.internal.routes.CompiledRoute
-            The _route to get the bucket for.
+            The route to get the bucket for.
 
         Returns
         -------
@@ -568,7 +555,7 @@ def acquire(self, compiled_route: routes.CompiledRoute) -> asyncio.Future[None]:
                 period=bucket.period,
             )
 
-        return bucket.acquire(self.max_rate_limit)
+        return bucket.acquire()
 
     def update_rate_limits(
         self,
@@ -584,7 +571,7 @@ def update_rate_limits(
         Parameters
         ----------
         compiled_route : hikari.internal.routes.CompiledRoute
-            The compiled _route to get the bucket for.
+            The compiled route to get the bucket for.
         bucket_header : typing.Optional[builtins.str]
             The `X-RateLimit-Bucket` header that was provided in the response.
         remaining_header : builtins.int
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index bdfddc2a..927ff56c 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -1449,7 +1449,9 @@ def deserialize_invite_with_metadata(self, payload: data_binding.JSONObject) ->
     # MESSAGE MODELS #
     ##################
 
-    def deserialize_partial_message(self, payload: data_binding.JSONObject) -> message_models.PartialMessage:
+    def deserialize_partial_message(  # noqa CFQ001 - Function too long
+        self, payload: data_binding.JSONObject
+    ) -> message_models.PartialMessage:
         author: typing.Optional[user_models.User] = None
         if author_pl := payload.get("author"):
             author = self.deserialize_user(author_pl)
@@ -1473,20 +1475,6 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             else:
                 edited_timestamp = None
 
-        is_mentioning_everyone = payload["mention_everyone"] if "mention_everyone" in payload else undefined.UNDEFINED
-
-        user_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mentions" in payload:
-            user_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mentions"]]
-
-        role_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mention_roles" in payload:
-            role_mentions = [snowflakes.Snowflake(mention) for mention in payload["mention_roles"]]
-
-        channel_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
-        if "mention_channels" in payload:
-            channel_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mention_channels"]]
-
         attachments: undefined.UndefinedOr[typing.MutableSequence[message_models.Attachment]] = undefined.UNDEFINED
         if "attachments" in payload:
             attachments = []
@@ -1554,7 +1542,7 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
                 else None,
             )
 
-        return message_models.PartialMessage(
+        message = message_models.PartialMessage(
             app=self._app,
             id=snowflakes.Snowflake(payload["id"]),
             channel_id=snowflakes.Snowflake(payload["channel_id"]),
@@ -1565,10 +1553,6 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             timestamp=timestamp,
             edited_timestamp=edited_timestamp,
             is_tts=payload["tts"] if "tts" in payload else undefined.UNDEFINED,
-            is_mentioning_everyone=is_mentioning_everyone,
-            user_mentions=user_mentions,
-            role_mentions=role_mentions,
-            channel_mentions=channel_mentions,
             attachments=attachments,
             embeds=embeds,
             reactions=reactions,
@@ -1580,8 +1564,36 @@ def deserialize_partial_message(self, payload: data_binding.JSONObject) -> messa
             message_reference=message_reference,
             flags=message_models.MessageFlag(payload["flags"]) if "flags" in payload else undefined.UNDEFINED,
             nonce=payload["nonce"] if "nonce" in payload else undefined.UNDEFINED,
+            # We initialize these next.
+            mentions=NotImplemented,
+        )
+
+        channels: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, channel_models.PartialChannel]]
+        channels = undefined.UNDEFINED
+        if raw_channels := payload.get("mention_channels"):
+            channels = {c.id: c for c in map(self.deserialize_partial_channel, raw_channels)}
+
+        users: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, user_models.User]]
+        users = undefined.UNDEFINED
+        if raw_users := payload.get("mentions"):
+            users = {u.id: u for u in map(self.deserialize_user, raw_users)}
+
+        role_ids: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = undefined.UNDEFINED
+        if raw_role_ids := payload.get("mention_roles"):
+            role_ids = [snowflakes.Snowflake(i) for i in raw_role_ids]
+
+        everyone = payload.get("mention_everyone", undefined.UNDEFINED)
+
+        message.mentions = message_models.Mentions(
+            message=message,
+            users=users,
+            role_ids=role_ids,
+            channels=channels,
+            everyone=everyone,
         )
 
+        return message
+
     def deserialize_message(self, payload: data_binding.JSONObject) -> message_models.Message:
         guild_id = snowflakes.Snowflake(payload["guild_id"]) if "guild_id" in payload else None
         author = self.deserialize_user(payload["author"])
@@ -1595,14 +1607,6 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
         if (raw_edited_timestamp := payload["edited_timestamp"]) is not None:
             edited_timestamp = time.iso8601_datetime_string_to_datetime(raw_edited_timestamp)
 
-        user_mentions = [snowflakes.Snowflake(mention["id"]) for mention in payload["mentions"]]
-        role_mentions = [snowflakes.Snowflake(mention) for mention in payload["mention_roles"]]
-        channel_mentions = (
-            [snowflakes.Snowflake(mention["id"]) for mention in payload["mention_channels"]]
-            if "mention_channels" in payload
-            else []
-        )
-
         attachments = []
         for attachment_payload in payload["attachments"]:
             attachment = message_models.Attachment(
@@ -1668,7 +1672,7 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
                 primary_sku_id=primary_sku_id,
             )
 
-        return message_models.Message(
+        message = message_models.Message(
             app=self._app,
             id=snowflakes.Snowflake(payload["id"]),
             channel_id=snowflakes.Snowflake(payload["channel_id"]),
@@ -1679,10 +1683,6 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
             timestamp=time.iso8601_datetime_string_to_datetime(payload["timestamp"]),
             edited_timestamp=edited_timestamp,
             is_tts=payload["tts"],
-            is_mentioning_everyone=payload["mention_everyone"],
-            user_mentions=user_mentions,
-            role_mentions=role_mentions,
-            channel_mentions=channel_mentions,
             attachments=attachments,
             embeds=embeds,
             reactions=reactions,
@@ -1694,8 +1694,34 @@ def deserialize_message(self, payload: data_binding.JSONObject) -> message_model
             message_reference=crosspost,
             flags=message_models.MessageFlag(payload["flags"]) if "flags" in payload else None,
             nonce=payload.get("nonce"),
+            # We initialize these next.
+            mentions=NotImplemented,
         )
 
+        channels: typing.Mapping[snowflakes.Snowflake, channel_models.PartialChannel] = {}
+        if raw_channels := payload.get("mention_channels"):
+            channels = {c.id: c for c in map(self.deserialize_partial_channel, raw_channels)}
+
+        users: typing.Mapping[snowflakes.Snowflake, user_models.User] = {}
+        if raw_users := payload.get("mentions"):
+            users = {u.id: u for u in map(self.deserialize_user, raw_users)}
+
+        role_ids: typing.Sequence[snowflakes.Snowflake] = []
+        if raw_role_ids := payload.get("mention_roles"):
+            role_ids = [snowflakes.Snowflake(i) for i in raw_role_ids]
+
+        everyone = payload.get("mention_everyone", False)
+
+        message.mentions = message_models.Mentions(
+            message=message,
+            users=users,
+            role_ids=role_ids,
+            channels=channels,
+            everyone=everyone,
+        )
+
+        return message
+
     ###################
     # PRESENCE MODELS #
     ###################
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index ce01ea57..dee031b9 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -66,7 +66,7 @@
 from hikari import users
 from hikari.api import cache as cache_
 from hikari.api import rest as rest_api
-from hikari.impl import buckets
+from hikari.impl import buckets as buckets_
 from hikari.impl import entity_factory as entity_factory_impl
 from hikari.impl import rate_limits
 from hikari.impl import special_endpoints
@@ -414,9 +414,10 @@ class RESTClientImpl(rest_api.RESTClient):
         "_proxy_settings",
         "_rest_url",
         "_token",
+        "_lock",
     )
 
-    buckets: buckets.RESTBucketManager
+    buckets: buckets_.RESTBucketManager
     """Bucket ratelimiter manager."""
 
     global_rate_limit: rate_limits.ManualRateLimiter
@@ -440,7 +441,7 @@ def __init__(
         token_type: typing.Optional[str] = None,
         rest_url: typing.Optional[str],
     ) -> None:
-        self.buckets = buckets.RESTBucketManager(max_rate_limit)
+        self.buckets = buckets_.RESTBucketManager(max_rate_limit)
         # We've been told in DAPI that this is per token.
         self.global_rate_limit = rate_limits.ManualRateLimiter()
 
@@ -452,6 +453,7 @@ def __init__(
         self._executor = executor
         self._http_settings = http_settings
         self._proxy_settings = proxy_settings
+        self._lock = asyncio.Lock()
 
         if token is None:
             full_token = None
@@ -551,55 +553,61 @@ async def _request(
 
         headers.put(_X_AUDIT_LOG_REASON_HEADER, reason)
 
+        url = compiled_route.create_url(self._rest_url)
         while True:
             try:
-                url = compiled_route.create_url(self._rest_url)
-
-                # Wait for any rate-limits to finish.
-                await asyncio.gather(self.buckets.acquire(compiled_route), self.global_rate_limit.acquire())
-
                 uuid = time.uuid()
 
-                if _LOGGER.getEffectiveLevel() <= ux.TRACE:
-                    _LOGGER.log(
-                        ux.TRACE,
-                        "%s %s %s\n%s",
-                        uuid,
+                # If we try to do more than 1 request to the same endpoint at
+                # the same time, there is a chance that we don't receive the
+                # ratelimits information before the next request comes along.
+                # Due to this, we risk getting ratelimited. This lock makes
+                # sure that there is only 1 request happening at the same time
+                # which stops this from happening.
+                async with self._lock:
+                    # Wait for any rate-limits to finish.
+                    await asyncio.gather(self.buckets.acquire(compiled_route), self.global_rate_limit.acquire())
+
+                    if _LOGGER.isEnabledFor(ux.TRACE):
+                        _LOGGER.log(
+                            ux.TRACE,
+                            "%s %s %s\n%s",
+                            uuid,
+                            compiled_route.method,
+                            url,
+                            self._stringify_http_message(headers, json),
+                        )
+
+                    # Make the request.
+                    session = self._acquire_client_session()
+                    start = time.monotonic()
+                    response = await session.request(
                         compiled_route.method,
                         url,
-                        self._stringify_http_message(headers, json),
+                        headers=headers,
+                        params=query,
+                        json=json,
+                        data=form,
+                        allow_redirects=self._http_settings.max_redirects is not None,
+                        max_redirects=self._http_settings.max_redirects,
+                        proxy=self._proxy_settings.url,
+                        proxy_headers=self._proxy_settings.all_headers,
                     )
-
-                # Make the request.
-                session = self._acquire_client_session()
-                start = time.monotonic()
-                response = await session.request(
-                    compiled_route.method,
-                    url,
-                    headers=headers,
-                    params=query,
-                    json=json,
-                    data=form,
-                    allow_redirects=self._http_settings.max_redirects is not None,
-                    max_redirects=self._http_settings.max_redirects,
-                    proxy=self._proxy_settings.url,
-                    proxy_headers=self._proxy_settings.all_headers,
-                )
-                time_taken = (time.monotonic() - start) * 1_000
-
-                if _LOGGER.getEffectiveLevel() <= ux.TRACE:
-                    _LOGGER.log(
-                        ux.TRACE,
-                        "%s %s %s in %sms\n%s",
-                        uuid,
-                        response.status,
-                        response.reason,
-                        time_taken,
-                        self._stringify_http_message(response.headers, await response.read()),
-                    )
-
-                # Ensure we are not rate limited, and update rate limiting headers where appropriate.
-                await self._parse_ratelimits(compiled_route, response)
+                    time_taken = (time.monotonic() - start) * 1_000
+
+                    if _LOGGER.isEnabledFor(ux.TRACE):
+                        _LOGGER.log(
+                            ux.TRACE,
+                            "%s %s %s in %sms\n%s",
+                            uuid,
+                            response.status,
+                            response.reason,
+                            time_taken,
+                            self._stringify_http_message(response.headers, await response.read()),
+                        )
+
+                    # Ensure we are not rate limited, and update rate limiting headers where appropriate.
+                    await self._parse_ratelimits(compiled_route, response)
 
                 # Don't bother processing any further if we got NO CONTENT. There's not anything
                 # to check.
@@ -641,9 +649,6 @@ async def _handle_error_response(response: aiohttp.ClientResponse) -> typing.NoR
 
     @typing.final
     async def _parse_ratelimits(self, compiled_route: routes.CompiledRoute, response: aiohttp.ClientResponse) -> None:
-        # Worth noting there is some bug on V6 that rate limits me immediately if I have an invalid token.
-        # https://github.com/discord/discord-api-docs/issues/1569
-
         # Handle rate limiting.
         resp_headers = response.headers
         limit = int(resp_headers.get(_X_RATELIMIT_LIMIT_HEADER, "1"))
@@ -654,8 +659,6 @@ async def _parse_ratelimits(self, compiled_route: routes.CompiledRoute, response
         reset_date = datetime.datetime.fromtimestamp(reset_at, tz=datetime.timezone.utc)
         now_date = time.rfc7231_datetime_string_to_datetime(resp_headers[_DATE_HEADER])
 
-        is_rate_limited = response.status == http.HTTPStatus.TOO_MANY_REQUESTS
-
         self.buckets.update_rate_limits(
             compiled_route=compiled_route,
             bucket_header=bucket,
@@ -665,9 +668,30 @@ async def _parse_ratelimits(self, compiled_route: routes.CompiledRoute, response
             reset_at_header=reset_date,
         )
 
-        if not is_rate_limited:
+        if response.status != http.HTTPStatus.TOO_MANY_REQUESTS:
             return
 
+        # Discord have started applying ratelimits to operations on some endpoints
+        # based on specific fields used in the JSON body.
+        # This does not get reflected in the headers. The first we know is when we
+        # get a 429.
+        # The issue is that we may get the same response if Discord dynamically
+        # adjusts the bucket ratelimits.
+        #
+        # We have no mechanism for handing field-based ratelimits, so if we get
+        # to here, but notice remaining is greater than zero, we should just error.
+        #
+        # Worth noting we still ignore the retry_after in the body. I have no clue
+        # if there is some weird edge case where a bucket rate limit can occur on
+        # top of a non-global one, but in this case this check will misbehave and
+        # instead of erroring, will trigger a backoff that might be 10 minutes or
+        # more...
+        #
+        # Seems Discord may raise this on some other undocumented cases, which
+        # is nice of them. Apparently some dude spamming slurs in the Python
+        # guild via a leaked webhook URL made people's clients exhibit this
+        # behaviour.
+
         # If we get ratelimited when running more than one bot under the same token,
         # or if the ratelimiting logic goes wrong, we will get a 429 and expect the
         # "remaining" header to be zeroed, or even negative as I don't trust that there
@@ -697,27 +721,6 @@ async def _parse_ratelimits(self, compiled_route: routes.CompiledRoute, response
 
             raise self._RetryRequest
 
-        # Discord have started applying ratelimits to operations on some endpoints
-        # based on specific fields used in the JSON body.
-        # This does not get reflected in the headers. The first we know is when we
-        # get a 429.
-        # The issue is that we may get the same response if Discord dynamically
-        # adjusts the bucket ratelimits.
-        #
-        # We have no mechanism for handing field-based ratelimits, so if we get
-        # to here, but notice remaining is greater than zero, we should just error.
-        #
-        # Worth noting we still ignore the retry_after in the body. I have no clue
-        # if there is some weird edge case where a bucket rate limit can occur on
-        # top of a non-global one, but in this case this check will misbehave and
-        # instead of erroring, will trigger a backoff that might be 10 minutes or
-        # more...
-        #
-        # Seems Discord may raise this on some other undocumented cases, which
-        # is nice of them. Apparently some dude spamming slurs in the Python
-        # guild via a leaked webhook URL made people's clients exhibit this
-        # behaviour.
-
         # If the values are within 20% of eachother by relativistic tolerance, it is probably
         # safe to retry the request, as they are likely the same value just with some
         # measuring difference. 20% was used as a rounded figure.
diff --git a/hikari/impl/shard.py b/hikari/impl/shard.py
index 83cd241f..41c1dfd9 100644
--- a/hikari/impl/shard.py
+++ b/hikari/impl/shard.py
@@ -156,7 +156,7 @@ async def receive_json(
         timeout: typing.Optional[float] = None,
     ) -> typing.Any:
         pl = await self._receive_and_check(timeout)
-        if self.logger.getEffectiveLevel() <= ux.TRACE:
+        if self.logger.isEnabledFor(ux.TRACE):
             filtered = self.log_filterer(pl)  # type: ignore
             self.logger.log(ux.TRACE, "received payload with size %s\n    %s", len(pl), filtered)
         return loads(pl)
@@ -169,7 +169,7 @@ async def send_json(
         dumps: aiohttp.typedefs.JSONEncoder = json.dumps,
     ) -> None:
         pl = dumps(data)
-        if self.logger.getEffectiveLevel() <= ux.TRACE:
+        if self.logger.isEnabledFor(ux.TRACE):
             filtered = self.log_filterer(pl)  # type: ignore
             self.logger.log(ux.TRACE, "sending payload with size %s\n    %s", len(pl), filtered)
         await self.send_str(pl, compress)
diff --git a/hikari/internal/collections.py b/hikari/internal/collections.py
index aad47a8b..d5de5ec5 100644
--- a/hikari/internal/collections.py
+++ b/hikari/internal/collections.py
@@ -23,7 +23,7 @@
 from __future__ import annotations
 
 __all__: typing.List[str] = [
-    "MapT",
+    "ExtendedMapT",
     "KeyT",
     "ValueT",
     "SnowflakeSet",
@@ -46,7 +46,7 @@
 
 from hikari import snowflakes
 
-MapT = typing.TypeVar("MapT", bound="ExtendedMutableMapping[typing.Any, typing.Any]")
+ExtendedMapT = typing.TypeVar("ExtendedMapT", bound="ExtendedMutableMapping[typing.Any, typing.Any]")
 """Type-hint A type hint used for mapped collection objects."""
 KeyT = typing.TypeVar("KeyT", bound=typing.Hashable)
 """Type-hint A type hint used for the type of a mapping's key."""
@@ -65,7 +65,7 @@ class ExtendedMutableMapping(typing.MutableMapping[KeyT, ValueT], abc.ABC):
     __slots__: typing.Sequence[str] = ()
 
     @abc.abstractmethod
-    def copy(self: MapT) -> MapT:
+    def copy(self: ExtendedMapT) -> ExtendedMapT:
         """Return a copy of this mapped collection.
 
         Unlike simply doing `dict(mapping)`, this may rely on internal detail
diff --git a/hikari/messages.py b/hikari/messages.py
index 8b203898..32f50de3 100644
--- a/hikari/messages.py
+++ b/hikari/messages.py
@@ -30,6 +30,7 @@
     "Attachment",
     "Reaction",
     "MessageActivity",
+    "Mentions",
     "MessageCrosspost",
     "PartialMessage",
     "Message",
@@ -41,7 +42,9 @@
 
 from hikari import files
 from hikari import guilds
+from hikari import intents
 from hikari import snowflakes
+from hikari import traits
 from hikari import undefined
 from hikari import urls
 from hikari.internal import attr_extensions
@@ -54,8 +57,9 @@
     from hikari import channels
     from hikari import embeds as embeds_
     from hikari import emojis as emojis_
-    from hikari import traits
-    from hikari import users
+    from hikari import users as users_
+
+_T = typing.TypeVar("_T")
 
 
 @typing.final
@@ -209,6 +213,136 @@ class MessageActivity:
     """The party ID of the message activity."""
 
 
+@attr_extensions.with_copy
+@attr.s(eq=True, hash=False, init=True, kw_only=True, slots=True, weakref_slot=False)
+class Mentions:
+    """Description of mentions that exist in the message."""
+
+    # We refer back to the containing message so that we can provide info about
+    # entities that were not notified, and provide access to cached roles
+    # through this mechanism.
+    _message: PartialMessage = attr.ib()
+
+    users: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, users_.User]] = attr.ib()
+    """Users who were notified by their mention in the message."""
+
+    role_ids: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib()
+    """IDs of roles that were notified by their mention in the message."""
+
+    channels: undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, channels.PartialChannel]] = attr.ib()
+    """Channel mentions that reference channels in the target crosspost's guild.
+
+    If the message is not crossposted, this will always be empty.
+    """
+
+    everyone: undefined.UndefinedOr[bool] = attr.ib()
+    """Whether the message notifies using `@everyone` or `@here`."""
+
+    @property
+    def channels_ids(self) -> undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]]:
+        if self.channels is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        return list(self.channels.keys())
+
+    @property
+    def user_ids(self) -> undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]]:
+        if self.users is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        return list(self.users.keys())
+
+    @property
+    def members(self) -> undefined.UndefinedOr[typing.Mapping[snowflakes.Snowflake, guilds.Member]]:
+        """Discover any cached members notified by this message.
+
+        If this message was sent in a DM, this will always be empty.
+
+        !!! warning
+            This will only return valid results on gateway events. For REST
+            endpoints, this will potentially be empty. This is a limitation of
+            Discord's API, as they do not consistently notify of the ID of the
+            guild a message was sent in.
+
+        !!! note
+            If you are using a stateless application such as a stateless bot
+            or a REST-only client, this will always be empty. Furthermore,
+            if you are running a stateful bot and have the GUILD_MEMBERS
+            intent disabled, this will also be empty.
+
+            Members that are not cached will not appear in this mapping. This
+            means that there is a very small chance that some users provided
+            in `notified_users` may not be present here.
+        """
+        if self.users is undefined.UNDEFINED:
+            return undefined.UNDEFINED
+
+        if self._ensure_cache(intents.Intents.GUILDS, True):
+            guild_id = typing.cast(snowflakes.Snowflake, self._message.guild_id)
+            app = typing.cast(traits.CacheAware, self._message.app)
+
+            return self._map_cache_maybe_discover(
+                self.users,
+                lambda user_id: app.cache.get_member(guild_id, user_id),
+            )
+
+        return {}
+
+    @property
+    def roles(self) -> typing.Mapping[snowflakes.Snowflake, guilds.Role]:
+        """Attempt to look up the roles that are notified by this message.
+
+        If this message was sent in a DM, this will always be empty.
+
+        !!! warning
+            This will only return valid results on gateway events. For REST
+            endpoints, this will potentially be empty. This is a limitation of
+            Discord's API, as they do not consistently notify of the ID of the
+            guild a message was sent in.
+
+        !!! note
+            If you are using a stateless application such as a stateless bot
+            or a REST-only client, this will always be empty. Furthermore,
+            if you are running a stateful bot and have the GUILD intent
+            disabled, this will also be empty.
+
+            Roles that are not cached will not appear in this mapping. This
+            means that there is a very small chance that some role IDs provided
+            in `notifies_role_ids` may not be present here. This is a limitation
+            of Discord, again.
+        """
+        if self._ensure_cache(intents.Intents.GUILDS, True):
+            app = typing.cast(traits.CacheAware, self._message.app)
+
+            return self._map_cache_maybe_discover(
+                self.roles,
+                app.cache.get_role,
+            )
+
+        return {}
+
+    def _ensure_cache(self, intents_required: intents.Intents, needs_guild: bool) -> bool:
+        app = self._message.app
+
+        return bool(
+            isinstance(app, traits.ShardAware)
+            and (app.intents & intents_required) == intents_required
+            and (not needs_guild or self._message.guild_id)
+        )
+
+    @staticmethod
+    def _map_cache_maybe_discover(
+        ids: typing.Iterable[snowflakes.Snowflake],
+        cache_call: typing.Callable[[snowflakes.Snowflake], typing.Optional[_T]],
+    ) -> typing.Dict[snowflakes.Snowflake, _T]:
+        results: typing.Dict[snowflakes.Snowflake, _T] = {}
+        for id_ in ids:
+            obj = cache_call(id_)
+            if obj is not None:
+                results[id_] = obj
+        return results
+
+
 @attr_extensions.with_copy
 @attr.s(eq=True, hash=False, init=True, kw_only=True, slots=True, weakref_slot=False)
 class MessageCrosspost:
@@ -326,10 +460,11 @@ class PartialMessage(snowflakes.Unique):
     channel_id: snowflakes.Snowflake = attr.ib(repr=True)
     """The ID of the channel that the message was sent in."""
 
-    guild_id: typing.Optional[snowflakes.Snowflake] = attr.ib(repr=True)
-    """The ID of the guild that the message was sent in."""
+    _guild_id: typing.Optional[snowflakes.Snowflake] = attr.ib(repr=True)
+    #: Try to determine this best-effort in the property defined further
+    #: down.
 
-    author: typing.Optional[users.User] = attr.ib(repr=True)
+    author: typing.Optional[users_.User] = attr.ib(repr=True)
     """The author of this message.
 
     This will be `builtins.None` in some cases such as when Discord
@@ -361,18 +496,15 @@ class PartialMessage(snowflakes.Unique):
     is_tts: undefined.UndefinedOr[bool] = attr.ib(repr=False)
     """Whether the message is a TTS message."""
 
-    is_mentioning_everyone: undefined.UndefinedOr[bool] = attr.ib(repr=False)
-    """Whether the message mentions `@everyone` or `@here`."""
-
-    # TODO: make a mentions object. These type hints are cancer in the documentation.
-    user_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The users the message mentions."""
+    mentions: Mentions = attr.ib(repr=True)
+    """Description of who is mentioned in a message.
 
-    role_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The roles the message mentions."""
+    !!! warning
+        If the contents have not mutated and this is a message update event,
+        some fields that are not affected may be empty instead.
 
-    channel_mentions: undefined.UndefinedOr[typing.Sequence[snowflakes.Snowflake]] = attr.ib(repr=False)
-    """The channels the message mentions."""
+        This is a Discord limitation.
+    """
 
     attachments: undefined.UndefinedOr[typing.Sequence[Attachment]] = attr.ib(repr=False)
     """The message attachments."""
@@ -410,6 +542,25 @@ class PartialMessage(snowflakes.Unique):
     This is a string used for validating a message was sent.
     """
 
+    @property
+    def guild_id(self) -> typing.Optional[snowflakes.Snowflake]:
+        """ID of the guild that the message was sent in.
+
+        This will not be present on REST API responses if the application is
+        stateless or missing the `GUILDS` intent.
+        """
+        if self._guild_id:
+            return self._guild_id
+
+        # Don't check the member, as if the guild_id is missing, the member
+        # will always be missing too.
+        channel = self.app.cache.get_guild_channel(self.channel_id)
+
+        if channel is None:
+            return None
+
+        return channel.guild_id
+
     @property
     def link(self) -> str:
         """Jump link to the message.
@@ -449,7 +600,7 @@ async def edit(
         embed: undefined.UndefinedNoneOr[embeds_.Embed] = undefined.UNDEFINED,
         mentions_everyone: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
         user_mentions: undefined.UndefinedOr[
-            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users.PartialUser]], bool]
+            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users_.PartialUser]], bool]
         ] = undefined.UNDEFINED,
         role_mentions: undefined.UndefinedOr[
             typing.Union[typing.Collection[snowflakes.SnowflakeishOr[guilds.PartialRole]], bool]
@@ -578,7 +729,7 @@ async def reply(
         nonce: undefined.UndefinedOr[str] = undefined.UNDEFINED,
         mentions_everyone: undefined.UndefinedOr[bool] = undefined.UNDEFINED,
         user_mentions: undefined.UndefinedOr[
-            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users.PartialUser]], bool]
+            typing.Union[typing.Collection[snowflakes.SnowflakeishOr[users_.PartialUser]], bool]
         ] = undefined.UNDEFINED,
         role_mentions: undefined.UndefinedOr[
             typing.Union[typing.Collection[snowflakes.SnowflakeishOr[guilds.PartialRole]], bool]
@@ -624,9 +775,8 @@ async def reply(
             If provided, and `builtins.True`, all mentions will be parsed.
             If provided, and `builtins.False`, no mentions will be parsed.
             Alternatively this may be a collection of
-            `hikari.snowflakes.Snowflake`, or
-            `hikari.users.PartialUser` derivatives to enforce mentioning
-            specific users.
+            `hikari.snowflakes.Snowflake`, or `hikari.users.PartialUser`
+            derivatives to enforce mentioning specific users.
         role_mentions : hikari.undefined.UndefinedOr[typing.Union[typing.Collection[hikari.snowflakes.SnowflakeishOr[hikari.guilds.PartialRole]], builtins.bool]]
             If provided, and `builtins.True`, all mentions will be parsed.
             If provided, and `builtins.False`, no mentions will be parsed.
@@ -776,7 +926,7 @@ async def remove_reaction(
         self,
         emoji: emojis_.Emojiish,
         *,
-        user: undefined.UndefinedOr[snowflakes.SnowflakeishOr[users.PartialUser]] = undefined.UNDEFINED,
+        user: undefined.UndefinedOr[snowflakes.SnowflakeishOr[users_.PartialUser]] = undefined.UNDEFINED,
     ) -> None:
         r"""Remove a reaction from this message.
 
@@ -883,10 +1033,7 @@ class Message(PartialMessage):
     # and possibly result in large amounts of unwasted memory if you get that
     # far.
 
-    guild_id: typing.Optional[snowflakes.Snowflake]
-    """The ID of the guild that the message was sent in."""
-
-    author: users.User
+    author: users_.User
     """The author of this message."""
 
     member: typing.Optional[guilds.Member]
@@ -907,17 +1054,8 @@ class Message(PartialMessage):
     is_tts: bool
     """Whether the message is a TTS message."""
 
-    is_mentioning_everyone: bool
-    """Whether the message mentions `@everyone` or `@here`."""
-
-    user_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The users the message mentions."""
-
-    role_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The roles the message mentions."""
-
-    channel_mentions: typing.Sequence[snowflakes.Snowflake]
-    """The channels the message mentions."""
+    mentions: Mentions
+    """Who is mentioned in a message."""
 
     attachments: typing.Sequence[Attachment]
     """The message attachments."""


Test patch:
diff --git a/tests/hikari/impl/test_buckets.py b/tests/hikari/impl/test_buckets.py
index 8d3ab00b..9792c10a 100644
--- a/tests/hikari/impl/test_buckets.py
+++ b/tests/hikari/impl/test_buckets.py
@@ -239,7 +239,7 @@ async def test_acquire_route_when_route_cached_already_obtains_hash_from_route_a
             mgr.acquire(route)
 
             # yes i test this twice, sort of. no, there isn't another way to verify this. sue me.
-            bucket.acquire.assert_called_once_with(float("inf"))
+            bucket.acquire.assert_called_once_with()
 
     @pytest.mark.asyncio
     async def test_acquire_route_returns_acquired_future(self):
diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py
index 35d19624..868285f4 100644
--- a/tests/hikari/impl/test_entity_factory.py
+++ b/tests/hikari/impl/test_entity_factory.py
@@ -2465,7 +2465,7 @@ def test_deserialize_partial_message(
         assert partial_message.app is mock_app
         assert partial_message.id == 123
         assert partial_message.channel_id == 456
-        assert partial_message.guild_id == 678
+        assert partial_message._guild_id == 678
         assert partial_message.author == entity_factory_impl.deserialize_user(user_payload)
         assert partial_message.member == entity_factory_impl.deserialize_member(
             member_payload, user=partial_message.author, guild_id=snowflakes.Snowflake(678)
@@ -2478,10 +2478,10 @@ def test_deserialize_partial_message(
             2020, 4, 21, 21, 20, 16, 510000, tzinfo=datetime.timezone.utc
         )
         assert partial_message.is_tts is True
-        assert partial_message.is_mentioning_everyone is True
-        assert partial_message.user_mentions == [5678]
-        assert partial_message.role_mentions == [987]
-        assert partial_message.channel_mentions == [456]
+        assert partial_message.mentions.everyone is True
+        assert partial_message.mentions.user_ids == [5678]
+        assert partial_message.mentions.role_ids == [987]
+        assert partial_message.mentions.channels_ids == [456]
         # Attachment
         assert len(partial_message.attachments) == 1
         attachment = partial_message.attachments[0]
@@ -2537,7 +2537,7 @@ def test_deserialize_partial_message_with_partial_fields(self, entity_factory_im
         message_payload["application"]["primary_sku_id"] = None
         partial_message = entity_factory_impl.deserialize_partial_message(message_payload)
         assert partial_message.edited_timestamp is None
-        assert partial_message.guild_id is not None
+        assert partial_message._guild_id is not None
         assert partial_message.member is None
         assert partial_message.application.primary_sku_id is None
 
@@ -2546,17 +2546,17 @@ def test_deserialize_partial_message_with_unset_fields(self, entity_factory_impl
         assert partial_message.app is mock_app
         assert partial_message.id == 123
         assert partial_message.channel_id == 456
-        assert partial_message.guild_id is None
+        assert partial_message._guild_id is None
         assert partial_message.author is None
         assert partial_message.member is None
         assert partial_message.content is undefined.UNDEFINED
         assert partial_message.timestamp is undefined.UNDEFINED
         assert partial_message.edited_timestamp is undefined.UNDEFINED
         assert partial_message.is_tts is undefined.UNDEFINED
-        assert partial_message.is_mentioning_everyone is undefined.UNDEFINED
-        assert partial_message.user_mentions is undefined.UNDEFINED
-        assert partial_message.role_mentions is undefined.UNDEFINED
-        assert partial_message.channel_mentions is undefined.UNDEFINED
+        assert partial_message.mentions.everyone is undefined.UNDEFINED
+        assert partial_message.mentions.user_ids is undefined.UNDEFINED
+        assert partial_message.mentions.role_ids is undefined.UNDEFINED
+        assert partial_message.mentions.channels_ids is undefined.UNDEFINED
         assert partial_message.attachments is undefined.UNDEFINED
         assert partial_message.embeds is undefined.UNDEFINED
         assert partial_message.reactions is undefined.UNDEFINED
@@ -2584,7 +2584,7 @@ def test_deserialize_full_message(
         assert message.app is mock_app
         assert message.id == 123
         assert message.channel_id == 456
-        assert message.guild_id == 678
+        assert message._guild_id == 678
         assert message.author == entity_factory_impl.deserialize_user(user_payload)
         assert message.member == entity_factory_impl.deserialize_member(
             member_payload, user=message.author, guild_id=snowflakes.Snowflake(678)
@@ -2595,10 +2595,10 @@ def test_deserialize_full_message(
             2020, 4, 21, 21, 20, 16, 510000, tzinfo=datetime.timezone.utc
         )
         assert message.is_tts is True
-        assert message.is_mentioning_everyone is True
-        assert message.user_mentions == [5678]
-        assert message.role_mentions == [987]
-        assert message.channel_mentions == [456]
+        assert message.mentions.everyone is True
+        assert message.mentions.user_ids == [5678]
+        assert message.mentions.role_ids == [987]
+        assert message.mentions.channels_ids == [456]
         # Attachment
         assert len(message.attachments) == 1
         attachment = message.attachments[0]
@@ -2674,12 +2674,13 @@ def test_deserialize_message_with_null_and_unset_fields(
 
         message = entity_factory_impl.deserialize_message(message_payload)
         assert message.app is mock_app
-        assert message.guild_id is None
+        assert message._guild_id is None
         assert message.member is None
         assert message.edited_timestamp is None
-        assert message.channel_mentions == []
-        assert message.role_mentions == []
-        assert message.channel_mentions == []
+        assert message.mentions.everyone is True
+        assert message.mentions.user_ids == []
+        assert message.mentions.role_ids == []
+        assert message.mentions.channels_ids == []
         assert message.attachments == []
         assert message.embeds == []
         assert message.reactions == []
diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py
index 97a24bb8..6b8a3d37 100644
--- a/tests/hikari/impl/test_rest.py
+++ b/tests/hikari/impl/test_rest.py
@@ -47,7 +47,6 @@
 from hikari.impl import special_endpoints
 from hikari.internal import net
 from hikari.internal import routes
-from hikari.internal import ux
 from tests.hikari import client_session_stub
 from tests.hikari import hikari_test_helpers
 
@@ -1004,9 +1003,9 @@ class StubResponse:
             with pytest.raises(exit_exception):
                 await rest_client._request(route)
 
-    @pytest.mark.parametrize("logger_level", [ux.TRACE, ux.TRACE + 10])
+    @pytest.mark.parametrize("enabled", [True, False])
     @hikari_test_helpers.timeout()
-    async def test__request_logger(self, rest_client, exit_exception, logger_level):
+    async def test__request_logger(self, rest_client, enabled):
         class StubResponse:
             status = http.HTTPStatus.NO_CONTENT
             headers = {}
@@ -1021,16 +1020,14 @@ async def read(self):
         rest_client._parse_ratelimits = mock.AsyncMock()
         rest_client._acquire_client_session = mock.Mock(return_value=mock_session)
 
-        with mock.patch.object(
-            rest, "_LOGGER", new=mock.Mock(getEffectiveLevel=mock.Mock(return_value=logger_level))
-        ) as logger:
+        with mock.patch.object(rest, "_LOGGER", new=mock.Mock(isEnabledFor=mock.Mock(return_value=enabled))) as logger:
             with mock.patch.object(asyncio, "gather", new=mock.AsyncMock()):
                 await rest_client._request(route)
 
-        if logger_level > ux.TRACE:
-            assert logger.log.call_count == 0
-        else:
+        if enabled:
             assert logger.log.call_count == 2
+        else:
+            assert logger.log.call_count == 0
 
     async def test__handle_error_response(self, rest_client, exit_exception):
         mock_response = mock.Mock()
diff --git a/tests/hikari/impl/test_shard.py b/tests/hikari/impl/test_shard.py
index 7972b95f..b9c73949 100644
--- a/tests/hikari/impl/test_shard.py
+++ b/tests/hikari/impl/test_shard.py
@@ -65,7 +65,7 @@ class Test_V6GatewayTransport:
     def transport_impl(self):
         with mock.patch.object(aiohttp.ClientWebSocketResponse, "__init__"):
             transport = shard._GatewayTransport()
-            transport.logger = mock.Mock(getEffectiveLevel=mock.Mock(return_value=5))
+            transport.logger = mock.Mock(isEnabledFor=mock.Mock(return_value=True))
             transport.log_filterer = mock.Mock()
             yield transport
 
diff --git a/tests/hikari/test_messages.py b/tests/hikari/test_messages.py
index 3c3e51e3..55ce6a6b 100644
--- a/tests/hikari/test_messages.py
+++ b/tests/hikari/test_messages.py
@@ -100,7 +100,7 @@ def test_format_cover_image_when_hash_is_not_none(self, message_application):
 @pytest.fixture()
 def message():
     return messages.Message(
-        app=mock.AsyncMock(),
+        app=None,
         id=snowflakes.Snowflake(1234),
         channel_id=snowflakes.Snowflake(5678),
         guild_id=snowflakes.Snowflake(910112),
@@ -110,10 +110,13 @@ def message():
         timestamp=datetime.datetime.now().astimezone(),
         edited_timestamp=None,
         is_tts=False,
-        is_mentioning_everyone=False,
-        user_mentions=(),
-        role_mentions=(),
-        channel_mentions=(),
+        mentions=messages.Mentions(
+            message=mock.Mock(),
+            users={},
+            role_ids=[],
+            channels={},
+            everyone=False,
+        ),
         attachments=(),
         embeds=(),
         reactions=(),
@@ -132,25 +135,43 @@ class TestMessage:
     def test_link_property_when_guild_is_not_none(self, message):
         message.id = 789
         message.channel_id = 456
-        message.guild_id = 123
+        message._guild_id = 123
         assert message.link == "https://discord.com/channels/123/456/789"
 
     def test_link_property_when_guild_is_none(self, message):
+        message.app = mock.Mock()
         message.id = 789
         message.channel_id = 456
-        message.guild_id = None
+        message._guild_id = None
+        message.app.cache.get_guild_channel.return_value = None
         assert message.link == "https://discord.com/channels/@me/456/789"
 
+    def test_guild_id_when_guild_is_not_none(self, message):
+        message._guild_id = 123
+
+        assert message.guild_id == 123
+
+    def test_guild_id_when_guild_is_none(self, message):
+        message.app = mock.Mock()
+        message._guild_id = None
+        message.channel_id = 890
+        message.app.cache.get_guild_channel = mock.Mock(return_value=mock.Mock(guild_id=456))
+
+        assert message.guild_id == 456
+
+        message.app.cache.get_guild_channel.assert_called_once_with(890)
+
 
-# TODO: this all needs to be moved to one test class.
 @pytest.mark.asyncio
 class TestAsyncMessage:
     async def test_fetch_channel(self, message):
+        message.app = mock.AsyncMock()
         message.channel_id = 123
         await message.fetch_channel()
         message.app.rest.fetch_channel.assert_awaited_once_with(123)
 
     async def test_edit(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         embed = object()
@@ -175,6 +196,7 @@ async def test_edit(self, message):
         )
 
     async def test_reply(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         embed = object()
@@ -206,24 +228,28 @@ async def test_reply(self, message):
         )
 
     async def test_delete(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.delete()
         message.app.rest.delete_message.assert_awaited_once_with(456, 123)
 
     async def test_add_reaction(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.add_reaction("👌")
         message.app.rest.add_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌")
 
     async def test_remove_reaction(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_reaction("👌")
         message.app.rest.delete_my_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌")
 
     async def test_remove_reaction_with_user(self, message):
+        message.app = mock.AsyncMock()
         user = object()
         message.id = 123
         message.channel_id = 456
@@ -231,12 +257,14 @@ async def test_remove_reaction_with_user(self, message):
         message.app.rest.delete_reaction.assert_awaited_once_with(channel=456, message=123, emoji="👌", user=user)
 
     async def test_remove_all_reactions(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_all_reactions()
         message.app.rest.delete_all_reactions.assert_awaited_once_with(channel=456, message=123)
 
     async def test_remove_all_reactions_with_emoji(self, message):
+        message.app = mock.AsyncMock()
         message.id = 123
         message.channel_id = 456
         await message.remove_all_reactions("👌")