From 043c69f24e05bd2620e184e69765d8a52f6e18dd Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 24 Jul 2024 19:02:20 +0200 Subject: [PATCH 1/7] Add base implementation for rate-limiting --- src/exts/error_handler/error_handler.py | 53 +++++++-- src/utils/ratelimit.py | 136 ++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 src/utils/ratelimit.py diff --git a/src/exts/error_handler/error_handler.py b/src/exts/error_handler/error_handler.py index 6c3a6e5..809b191 100644 --- a/src/exts/error_handler/error_handler.py +++ b/src/exts/error_handler/error_handler.py @@ -1,12 +1,13 @@ import textwrap from typing import cast -from discord import Any, ApplicationContext, Cog, Colour, Embed, errors +from discord import Any, ApplicationContext, Cog, Colour, Embed, EmbedField, EmbedFooter, errors from discord.ext.commands import errors as commands_errors from src.bot import Bot from src.settings import FAIL_EMOJI, GITHUB_REPO from src.utils.log import get_logger +from src.utils.ratelimit import RateLimitExceededError log = get_logger(__name__) @@ -23,12 +24,20 @@ async def send_error_embed( *, title: str | None = None, description: str | None = None, + fields: list[EmbedField] | None = None, + footer: EmbedFooter | None = None, ) -> None: """Send an embed regarding the unhandled exception that occurred.""" if title is None and description is None: raise ValueError("You need to provide either a title or a description.") - embed = Embed(title=title, description=description, color=Colour.red()) + embed = Embed( + title=title, + description=description, + color=Colour.red(), + fields=fields, + footer=footer, + ) await ctx.respond(f"Sorry, {ctx.author.mention}", embed=embed) async def send_unhandled_embed(self, ctx: ApplicationContext, exc: BaseException) -> None: @@ -128,6 +137,38 @@ async def _handle_check_failure( await self.send_unhandled_embed(ctx, exc) + async def _handle_command_invoke_error( + self, + ctx: ApplicationContext, + exc: errors.ApplicationCommandInvokeError, + ) -> None: + original_exception = exc.__cause__ + + if original_exception is None: + await self.send_unhandled_embed(ctx, exc) + log.exception("Got ApplicationCommandInvokeError without a cause.", exc_info=exc) + return + + if isinstance(original_exception, RateLimitExceededError): + msg = original_exception.msg or "Hit a rate-limit, please try again later." + time_remaining = f"Expected reset: " + footer = None + if original_exception.updates_when_exceeded: + footer = EmbedFooter( + text="Spamming the command will only increase the time you have to wait.", + ) + await self.send_error_embed( + ctx, + title="Rate limit exceeded", + description=f"{FAIL_EMOJI} {msg}", + fields=[EmbedField(name="", value=time_remaining)], + footer=footer, + ) + return + + await self.send_unhandled_embed(ctx, original_exception) + log.exception("Unhandled exception occurred.", exc_info=original_exception) + @Cog.listener() async def on_application_command_error(self, ctx: ApplicationContext, exc: errors.DiscordException) -> None: """Handle exceptions that have occurred while running some command.""" @@ -136,12 +177,8 @@ async def on_application_command_error(self, ctx: ApplicationContext, exc: error return if isinstance(exc, errors.ApplicationCommandInvokeError): - original_exception = exc.__cause__ - - if original_exception is not None: - await self.send_unhandled_embed(ctx, original_exception) - log.exception("Unhandled exception occurred.", exc_info=original_exception) - return + await self._handle_command_invoke_error(ctx, exc) + return await self.send_unhandled_embed(ctx, exc) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py new file mode 100644 index 0000000..c36b0d3 --- /dev/null +++ b/src/utils/ratelimit.py @@ -0,0 +1,136 @@ +import time +from typing import cast + +from aiocache import BaseCache + +from src.utils.log import get_logger + +log = get_logger(__name__) + + +class RateLimitExceededError(Exception): + """Exception raised when a rate limit was exceeded.""" + + def __init__( # noqa: PLR0913 + self, + msg: str | None, + *, + key: str, + limit: int, + period: float, + closest_expiration: float, + updates_when_exceeded: bool, + ) -> None: + """Initialize the rate limit exceeded error. + + :param msg: + Custom error message to include in the exception. + + This exception should also be shown to the user if the exception makes its way + to the error handler. If this is not provided, a generic message will be used. + :param key: Cache key that was rate-limit. + :param period: The period of time in seconds, in which the limit is enforced. + :param closest_expiration: The unix time-stamp of the closest expiration of the rate limit. + :param updates_when_exceeded: Does the rate limit get updated even if it was exceeded. + """ + self.msg = msg + self.key = key + self.limit = limit + self.period = period + self.closest_expiration = closest_expiration + self.updates_when_exceeded = updates_when_exceeded + + err_msg = f"Rate limit exceeded for key '{key}' ({limit}/{period}s)" + if msg: + err_msg += f": {msg}" + super().__init__(err_msg) + + +async def rate_limit( + cache: BaseCache, + key: str, + *, + limit: int, + period: float, + update_when_exceeded: bool = False, + err_msg: str | None = None, +) -> None: + """Log a new request for given key, enforcing the rate limit. + + The cache keys are name-spaced under 'rate-limit' to avoid conflicts with other cache keys. + + The rate-limiting uses a sliding window approach, where each request has its own expiration time + (i.e. a request is allowed if it is within the last `period` seconds). The requests are stored + as time-stamps under given key in the cache. + + :param cache: The cache instance used to keep track of the rate limits. + :param key: The key to use for this rate-limit. + :param limit: The number of requests allowed in the period. + :param period: The period of time in seconds, in which the limit is enforced. + :param update_when_exceeded: + Log a new rate-limit request time even if the limit was exceeded. + + This can be useful to disincentivize users from spamming requests, as they would + otherwise still receive the response eventually. With this behavior, they will + actually need to wait and not spam requests. + + By default, this behavior is disabled, mainly for global / internal rate limits. + :param err_msg: + Custom error message to include in the `RateLimitExceededError` exception. + + This message will be caught by the error handler and sent to the user, instead + of using a more generic message. + :raises RateLimitExceededError: If the rate limit was exceeded. + """ + current_timestamp = time.time() + + # No existing requests + if not await cache.exists(key, namespace="rate-limit"): + log.trace(f"No existing rate-limit requests for key {key!r}, adding the first one") + await cache.set(key, (current_timestamp,), ttl=period, namespace="rate-limit") + return + + # Get the existing requests + cache_time_stamps = cast(tuple[float, ...], await cache.get(key, namespace="rate-limit")) + log.trace(f"Fetched {len(cache_time_stamps)} existing requests for key {key!r}") + + # Expire requests older than the period + remaining_time_stamps = list(cache_time_stamps) + for time_stamp in cache_time_stamps: + if (current_timestamp - time_stamp) > period: + remaining_time_stamps.remove(time_stamp) + + # Also remove the oldest requests, keeping only up to limit + # This is just to avoid the list growing for no reason. + # As an advantage, it also makes it easier to find the closest expiration time. + remaining_time_stamps = remaining_time_stamps[-limit:] + + log.trace(f"Found {len(remaining_time_stamps)} non-expired existing requests for key {key!r}") + + # Add the new request, along with the existing non-expired ones, resetting the key + # Only do this if the rate limit wasn't exceeded, or if updating on exceeded requests is enabled + if len(remaining_time_stamps) < limit or update_when_exceeded: + log.trace("Updating rate limit with the new request") + new_timestamps: tuple[float, ...] = (*remaining_time_stamps, current_timestamp) + await cache.set(key, new_timestamps, ttl=period, namespace="rate-limit") + + # Check if the limit was exceeded + if len(remaining_time_stamps) >= limit: + # If update on exceeded requests are enabled, add the current timestamp to the list + # and trim to limit requests, allowing us to obtain the proper closest timestamp + if update_when_exceeded: + remaining_time_stamps.append(current_timestamp) + remaining_time_stamps = remaining_time_stamps[-limit:] + + closest_expiration = min(remaining_time_stamps) + period + + log.debug(f"Rate limit exceeded on key: {key!r}") + log.trace(f"Exceeded rate limit details: {limit}/{period}s, {remaining_time_stamps=!r}, {closest_expiration=}") + raise RateLimitExceededError( + err_msg, + key=key, + limit=limit, + period=period, + closest_expiration=closest_expiration, + updates_when_exceeded=update_when_exceeded, + ) From 0f2bdc19ce19c8544fef53c47339009639f221e7 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 17:20:11 +0200 Subject: [PATCH 2/7] Use a single tvdb client across the cog --- src/exts/tvdb_info/main.py | 67 ++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index 0655c98..c5e6bdd 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Literal -import aiohttp import discord from discord import ApplicationContext, Cog, option, slash_command @@ -106,6 +105,7 @@ class InfoCog(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + self.tvdb_client = TvdbClient(self.bot.http_session, self.bot.cache) @slash_command() @option("query", input_type=str, description="The query to search for.") @@ -128,37 +128,40 @@ async def search( ) -> None: """Search for a movie or series.""" await ctx.defer() - async with aiohttp.ClientSession() as session: - client = TvdbClient(session, self.bot.cache) - if by_id: - if query.startswith("movie-"): - entity_type = "movie" - query = query[6:] - elif query.startswith("series-"): - entity_type = "series" - query = query[7:] - try: - match entity_type: - case "movie": - response = [await Movie.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case "series": - response = [await Series.fetch(query, client, extended=True, meta=FetchMeta.TRANSLATIONS)] - case None: - await ctx.respond( - "You must specify a type (movie or series) when searching by ID.", ephemeral=True - ) - return - except InvalidIdError: - await ctx.respond( - 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.', - ephemeral=True, - ) - return - else: - response = await client.search(query, limit=5, entity_type=entity_type) - if not response: - await ctx.respond("No results found.") - return + + if by_id: + if query.startswith("movie-"): + entity_type = "movie" + query = query[6:] + elif query.startswith("series-"): + entity_type = "series" + query = query[7:] + try: + match entity_type: + case "movie": + response = [ + await Movie.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) + ] + case "series": + response = [ + await Series.fetch(query, self.tvdb_client, extended=True, meta=FetchMeta.TRANSLATIONS) + ] + case None: + await ctx.respond( + "You must specify a type (movie or series) when searching by ID.", ephemeral=True + ) + return + except InvalidIdError: + await ctx.respond( + 'Invalid ID. Id must be an integer, or "movie-" / "series-" followed by an integer.', + ephemeral=True, + ) + return + else: + response = await self.tvdb_client.search(query, limit=5, entity_type=entity_type) + if not response: + await ctx.respond("No results found.") + return view = InfoView(response) await view.send(ctx) From 2ac8d273c6a6fc162730a87cedd3fdbfbb80d217 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Wed, 24 Jul 2024 19:12:47 +0200 Subject: [PATCH 3/7] Apply tvdb client wide rate-limit --- README.md | 20 +++++++++++--------- src/settings.py | 10 ++++++++++ src/tvdb/client.py | 13 ++++++++++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b623084..9d817c2 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,17 @@ convenient. TODO: Separate these to variables necessary to run the bot, and those only relevant during development. --> -| Variable name | Type | Default | Description | -| ---------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------ | -| `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | -| `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | -| `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | -| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | -| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | -| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | -| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | +| Variable name | Type | Default | Description | +| -------------------------- | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------- | +| `BOT_TOKEN` | string | N/A | Bot token of the discord application (see: [this guide][bot-token-guide] if you don't have one yet) | +| `TVDB_API_KEY` | string | N/A | API key for TVDB (see [this page][tvdb-api-page] if you don't have one yet) | +| `TVDB_RATE_LIMIT_REQUESTS` | int | 5 | Amount of requests that the bot is allowed to make to the TVDB API within `TVDB_RATE_LIMIT_PERIOD` | +| `TVDB_RATE_LIMIT_PERIOD` | float | 5 | Period of time in seconds, within which the bot can make up to `TVDB_RATE_LIMIT_REQUESTS` requests to the TVDB API. | +| `SQLITE_DATABASE_FILE` | path | ./database.db | Path to sqlite database file, can be relative to project root (if the file doesn't yet exists, it will be created) | +| `ECHO_SQL` | bool | 0 | If `1`, print out every SQL command that SQLAlchemy library runs internally (can be useful when debugging) | +| `DEBUG` | bool | 0 | If `1`, debug logs will be enabled, if `0` only info logs and above will be shown | +| `LOG_FILE` | path | N/A | If set, also write the logs into given file, otherwise, only print them | +| `TRACE_LEVEL_FILTER` | custom | N/A | Configuration for trace level logging, see: [trace logs config section](#trace-logs-config) | [bot-token-guide]: https://guide.pycord.dev/getting-started/creating-your-first-bot#creating-the-bot-application [tvdb-api-page]: https://www.thetvdb.com/api-information diff --git a/src/settings.py b/src/settings.py index 96611f3..0e4248c 100644 --- a/src/settings.py +++ b/src/settings.py @@ -18,3 +18,13 @@ "Metadata provided by TheTVDB. Please consider adding missing information or subscribing at " "thetvdb.com." ) THETVDB_LOGO = "https://www.thetvdb.com/images/attribution/logo1.png" + +# The default rate-limit might be a bit too small for production-ready bots that live +# on multiple guilds. But it's good enough for our demonstration purposes and it's +# still actually quite hard to hit this rate-limit on a single guild, unless multiple +# people actually try to make many requests after each other.. +# +# Note that tvdb doesn't actually have rate-limits (or at least they aren't documented), +# but we should still be careful not to spam the API too much and be on the safe side. +TVDB_RATE_LIMIT_REQUESTS = get_config("TVDB_RATE_LIMIT_REQUESTS", cast=int, default=5) +TVDB_RATE_LIMIT_PERIOD = get_config("TVDB_RATE_LIMIT_PERIOD", cast=float, default=5) # seconds diff --git a/src/tvdb/client.py b/src/tvdb/client.py index 932e554..0146fe0 100644 --- a/src/tvdb/client.py +++ b/src/tvdb/client.py @@ -6,7 +6,7 @@ from aiocache import BaseCache from yarl import URL -from src.settings import TVDB_API_KEY +from src.settings import TVDB_API_KEY, TVDB_RATE_LIMIT_PERIOD, TVDB_RATE_LIMIT_REQUESTS from src.tvdb.generated_models import ( MovieBaseRecord, MovieExtendedRecord, @@ -21,6 +21,7 @@ ) from src.utils.iterators import get_first from src.utils.log import get_logger +from src.utils.ratelimit import rate_limit from .errors import BadCallError, InvalidApiKeyError, InvalidIdError @@ -266,6 +267,16 @@ async def request( """Make an authorized request to the TVDB API.""" log.trace(f"Making TVDB {method} request to {endpoint}") + # TODO: It would be better to instead use a queue to handle rate-limits + # and block until the next request can be made. + await rate_limit( + self.cache, + "tvdb", + limit=TVDB_RATE_LIMIT_REQUESTS, + period=TVDB_RATE_LIMIT_PERIOD, + err_msg="Bot wide rate-limit for TheTVDB API was exceeded.", + ) + if self.auth_token is None: log.trace("No auth token found, requesting initial login.") await self._login() From 3b61f4c620f8d3e4fdb8e9378c14b8e3a067b6eb Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 14:46:35 +0200 Subject: [PATCH 4/7] Add rate limit decorator function --- src/utils/ratelimit.py | 75 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py index c36b0d3..e737791 100644 --- a/src/utils/ratelimit.py +++ b/src/utils/ratelimit.py @@ -1,8 +1,12 @@ import time -from typing import cast +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Concatenate, cast from aiocache import BaseCache +from discord import ApplicationContext, Cog +from src.bot import Bot from src.utils.log import get_logger log = get_logger(__name__) @@ -134,3 +138,72 @@ async def rate_limit( closest_expiration=closest_expiration, updates_when_exceeded=update_when_exceeded, ) + + +type CogCommandFunction[T: Cog, **P] = Callable[Concatenate[T, ApplicationContext, P], Awaitable[None]] +type TransformFunction[T, R] = Callable[[T, ApplicationContext], R] + + +def rate_limited[T: Cog, **P]( + key: str | Callable[[T, ApplicationContext], str], + *, + limit: int | TransformFunction[T, int], + period: float | TransformFunction[T, float], + update_when_exceeded: bool | TransformFunction[T, bool] = False, + err_msg: str | None | TransformFunction[T, str | None] = None, +) -> Callable[[CogCommandFunction[T, P]], CogCommandFunction[T, P]]: + """Apply rate limits to given cog command function. + + The decorated function must be a slash command function that belongs to a cog class + (as an instance method). Make sure to apply this decorator before the ``slash_command`` + decorator. + + This uses the :func:`rate_limit` function internally to enforce the rate limits. + See its description for more info. + + All of the parameters can be set directly, or they can be callables, which will get called + with self (the cog instance) and ctx, using the return value as the value of that parameter. + These parameters will then all be forwarded to the ``rate_limit`` function. + + .. note:: + Py-cord does provide a built-in way to rate-limit commands through "cooldown" structures. + These work similarly to our custom implementation, but bucketing isn't as flexible and + doesn't work globally across the whole application. + + Using this decorator is therefore preferred, even for simple rate limits, for consistency. + """ + + def inner(func: CogCommandFunction[T, P]) -> CogCommandFunction[T, P]: + @wraps(func) + async def wrapper(self: T, ctx: ApplicationContext, *args: P.args, **kwargs: P.kwargs) -> None: + bot = ctx.bot + if not isinstance(bot, Bot): + raise TypeError( + "The bot instance must be of our custom Bot type (src.bot.Bot), " + f"found: {bot.__class__.__qualname__}" + ) + + cache = bot.cache + + # Call transformer functions, if used + key_ = key(self, ctx) if isinstance(key, Callable) else key + limit_ = limit(self, ctx) if isinstance(limit, Callable) else limit + period_ = period(self, ctx) if isinstance(period, Callable) else period + update_when_exceeded_ = ( + update_when_exceeded(self, ctx) if isinstance(update_when_exceeded, Callable) else update_when_exceeded + ) + err_msg_ = err_msg(self, ctx) if isinstance(err_msg, Callable) else err_msg + + await rate_limit( + cache, + key_, + limit=limit_, + period=period_, + update_when_exceeded=update_when_exceeded_, + err_msg=err_msg_, + ) + return await func(self, ctx, *args, **kwargs) + + return wrapper + + return inner From bb3f7e70ceb12af964feeed693b5d86612aac9d0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 15:50:39 +0200 Subject: [PATCH 5/7] Add prefix_key bool arg to the rate limit decorator --- src/utils/ratelimit.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/utils/ratelimit.py b/src/utils/ratelimit.py index e737791..624aaf7 100644 --- a/src/utils/ratelimit.py +++ b/src/utils/ratelimit.py @@ -151,6 +151,7 @@ def rate_limited[T: Cog, **P]( period: float | TransformFunction[T, float], update_when_exceeded: bool | TransformFunction[T, bool] = False, err_msg: str | None | TransformFunction[T, str | None] = None, + prefix_key: bool = False, ) -> Callable[[CogCommandFunction[T, P]], CogCommandFunction[T, P]]: """Apply rate limits to given cog command function. @@ -161,9 +162,11 @@ def rate_limited[T: Cog, **P]( This uses the :func:`rate_limit` function internally to enforce the rate limits. See its description for more info. - All of the parameters can be set directly, or they can be callables, which will get called - with self (the cog instance) and ctx, using the return value as the value of that parameter. - These parameters will then all be forwarded to the ``rate_limit`` function. + All of the parameters (except `prefix_key`) can be set directly, or they can be callables, + which will get called with self (the cog instance) and ctx, using the return value as the + value of that parameter. These parameters will then all be forwarded to the ``rate_limit`` function. + + :param prefix_key: Whether to prefix the key with the hash of the slash command function object. .. note:: Py-cord does provide a built-in way to rate-limit commands through "cooldown" structures. @@ -194,6 +197,9 @@ async def wrapper(self: T, ctx: ApplicationContext, *args: P.args, **kwargs: P.k ) err_msg_ = err_msg(self, ctx) if isinstance(err_msg, Callable) else err_msg + if prefix_key: + key_ = f"{hash(func)}-{key_}" + await rate_limit( cache, key_, From 8e4805bd2612c5c19114fc0948b60ea5f4d86cdc Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 17:25:13 +0200 Subject: [PATCH 6/7] Ignore debug logs from aiocache --- src/utils/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/log.py b/src/utils/log.py index 1372a59..6ed3180 100644 --- a/src/utils/log.py +++ b/src/utils/log.py @@ -196,5 +196,6 @@ def _setup_external_log_levels(root_log: LoggerClass) -> None: get_logger("discord.gateway").setLevel(logging.WARNING) get_logger("aiosqlite").setLevel(logging.INFO) get_logger("alembic.runtime.migration").setLevel(logging.WARNING) + get_logger("aiocache.base").setLevel(logging.INFO) get_logger("parso").setLevel(logging.WARNING) # For usage in IPython From 617e2d7460c5e2dac7540f158d9f9e8c9b502f7e Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 25 Jul 2024 18:12:53 +0200 Subject: [PATCH 7/7] Enforce per-user rate limits on /search --- src/exts/tvdb_info/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/exts/tvdb_info/main.py b/src/exts/tvdb_info/main.py index c5e6bdd..0a90890 100644 --- a/src/exts/tvdb_info/main.py +++ b/src/exts/tvdb_info/main.py @@ -9,6 +9,7 @@ from src.tvdb import FetchMeta, Movie, Series, TvdbClient from src.tvdb.errors import InvalidIdError from src.utils.log import get_logger +from src.utils.ratelimit import rate_limited log = get_logger(__name__) @@ -118,6 +119,7 @@ def __init__(self, bot: Bot) -> None: required=False, ) @option("by_id", input_type=bool, description="Search by tvdb ID.", required=False) + @rate_limited(key=lambda self, ctx: f"{ctx.user}", limit=2, period=8, update_when_exceeded=True, prefix_key=True) async def search( self, ctx: ApplicationContext,